Fix scheduled tasks failing to authenticate, and show when one is running #24

Merged
jknapp merged 3 commits from fix/scheduler-home-clobber into main 2026-08-12 13:55:54 +00:00
Owner

Scheduled tasks have been failing with Not logged in · Please run /login even though the container's OAuth credential is present and valid.

Cause

entrypoint.sh snapshots the environment into ~/.claude/scheduler/.env so cron jobs get more than cron's minimal env. It runs as root, and HOME was in the capture list — so the file records HOME='/root'.

triple-c-task-runner then sources that file with set -a, which overwrites the HOME cron gave the job. claude -p looks for its credential under $HOME, there is no /root/.claude, and it exits 1.

Logging still works because SCHEDULER_DIR is expanded at the top of the runner, before the sourcing — which is why this presents as a well-formed log of a task that never authenticated:

=== Permission mode: bypass ===

Not logged in · Please run /login

=== Exit code: 1 ===

Reproduced and confirmed in a live container:

HOME=/root         claude -p "reply OK"  ->  Not logged in · Please run /login
HOME=/home/claude  claude -p "reply OK"  ->  OK

Fix

  • entrypoint.sh — drop HOME from the captured set, and write HOME='/home/claude' explicitly afterwards. Cron does still need a HOME; it just must not be root's.
  • triple-c-task-runner — save and restore HOME across the source. .env lives on the home volume, so every project created before this ships keeps a stale copy of it until its container restarts. The runner is the part that has to survive that, and it makes the runner robust against anything else that writes the file.

Reach

Both files are COPY'd into /usr/local/bin, i.e. the system layer — so a base-image migration delivers this to existing projects, and new projects get it on first build. Projects that are never migrated keep the old shims in their snapshot image and stay broken; that is the usual snapshot-lineage rule, not something specific to this change.

Testing

  • bash -n on both scripts.
  • Ran the entrypoint's capture loop under env -i HOME=/root ...: HOME='/home/claude' is written exactly once and every other captured var is unchanged.
  • Ran the runner's sourcing block against an .env containing HOME='/root': HOME stays /home/claude, stays exported to children, and the other variables from the file still apply.
  • Applied the equivalent .env correction to a live container and ran a throwaway task end to end through triple-c-scheduler run — exit code 0.

Second commit: say when a task is running

The bug above hid behind a second problem — you cannot tell a scheduled run apart from a dead one. Runs are detached (cron has no terminal; the app fires a detached exec), and claude -p writes its answer in one burst at the end, so a healthy run shows nothing but its log header for as long as it is thinking. Triggering a task and watching the log looked exactly like a stall.

triple-c-task-runner now publishes ~/.claude/scheduler/running/<id>.json (pid, start time, log path) and removes it from an EXIT trap. flock remains what actually prevents overlapping runs; this file is purely observability, so every reader verifies the pid rather than trusting the file — a container stopped mid-run cannot fire a trap, and a task stuck on "running" forever is a worse lie than no indicator at all. Stale files are cleared on read.

Built on that:

  • list grows a status column — running 4m12s / idle.
  • status [--id ID] [--watch] — new; elapsed time, pid, log path, and the log tail when there is output yet.
  • run streams the log instead of blocking silently, and refuses to double-start a running task.
  • Automation tab marks a running task (busy tone + elapsed), disables its Run now, and polls while anything is in flight — including the second or two between firing a run and the runner registering it, which is the window that used to read as dead.
  • Container CLAUDE.md instructions and HOW-TO-USE updated, including the "log silence is not a stall" point.

Testing

  • Exercised in a live container: list and status during a real 30s run (running 12s, pid, log path), the double-run guard, run streaming to the terminal, status on an idle task showing its last result and exit code, and the EXIT trap clearing state on completion.
  • Crash path: planted a state file with a dead pid — list reports idle and the file is removed, rather than a run that never ends.
  • Verified the Rust listing script emits running_since_epoch non-null only for a live pid.
  • Two set -e traps found and fixed while testing: a grep matching nothing (empty log, the normal first seconds of a run) and an ls over an empty log dir both aborted status outright.
  • cargo check, tsc --noEmit, and the full Vitest suite pass — 352 tests, including 4 new AutomationTab tests covering the indicator, the disabled trigger, polling after a trigger, and not polling when idle.
Scheduled tasks have been failing with `Not logged in · Please run /login` even though the container's OAuth credential is present and valid. ## Cause `entrypoint.sh` snapshots the environment into `~/.claude/scheduler/.env` so cron jobs get more than cron's minimal env. It runs **as root**, and `HOME` was in the capture list — so the file records `HOME='/root'`. `triple-c-task-runner` then sources that file with `set -a`, which overwrites the `HOME` cron gave the job. `claude -p` looks for its credential under `$HOME`, there is no `/root/.claude`, and it exits 1. Logging still works because `SCHEDULER_DIR` is expanded at the top of the runner, before the sourcing — which is why this presents as a well-formed log of a task that never authenticated: ``` === Permission mode: bypass === Not logged in · Please run /login === Exit code: 1 === ``` Reproduced and confirmed in a live container: ``` HOME=/root claude -p "reply OK" -> Not logged in · Please run /login HOME=/home/claude claude -p "reply OK" -> OK ``` ## Fix - `entrypoint.sh` — drop `HOME` from the captured set, and write `HOME='/home/claude'` explicitly afterwards. Cron does still need a `HOME`; it just must not be root's. - `triple-c-task-runner` — save and restore `HOME` across the `source`. `.env` lives on the **home volume**, so every project created before this ships keeps a stale copy of it until its container restarts. The runner is the part that has to survive that, and it makes the runner robust against anything else that writes the file. ## Reach Both files are `COPY`'d into `/usr/local/bin`, i.e. the system layer — so a base-image migration delivers this to existing projects, and new projects get it on first build. Projects that are never migrated keep the old shims in their snapshot image and stay broken; that is the usual snapshot-lineage rule, not something specific to this change. ## Testing - `bash -n` on both scripts. - Ran the entrypoint's capture loop under `env -i HOME=/root ...`: `HOME='/home/claude'` is written exactly once and every other captured var is unchanged. - Ran the runner's sourcing block against an `.env` containing `HOME='/root'`: `HOME` stays `/home/claude`, stays exported to children, and the other variables from the file still apply. - Applied the equivalent `.env` correction to a live container and ran a throwaway task end to end through `triple-c-scheduler run` — exit code 0. --- ## Second commit: say when a task is running The bug above hid behind a second problem — you cannot tell a scheduled run apart from a dead one. Runs are detached (cron has no terminal; the app fires a detached exec), and `claude -p` writes its answer in one burst at the end, so a *healthy* run shows nothing but its log header for as long as it is thinking. Triggering a task and watching the log looked exactly like a stall. `triple-c-task-runner` now publishes `~/.claude/scheduler/running/<id>.json` (pid, start time, log path) and removes it from an `EXIT` trap. `flock` remains what actually prevents overlapping runs; this file is purely observability, so **every reader verifies the pid rather than trusting the file** — a container stopped mid-run cannot fire a trap, and a task stuck on "running" forever is a worse lie than no indicator at all. Stale files are cleared on read. Built on that: - `list` grows a status column — `running 4m12s` / `idle`. - `status [--id ID] [--watch]` — new; elapsed time, pid, log path, and the log tail when there is output yet. - `run` streams the log instead of blocking silently, and refuses to double-start a running task. - Automation tab marks a running task (`busy` tone + elapsed), disables its **Run now**, and polls while anything is in flight — including the second or two between firing a run and the runner registering it, which is the window that used to read as dead. - Container CLAUDE.md instructions and HOW-TO-USE updated, including the "log silence is not a stall" point. ### Testing - Exercised in a live container: `list` and `status` during a real 30s run (`running 12s`, pid, log path), the double-run guard, `run` streaming to the terminal, `status` on an idle task showing its last result and exit code, and the EXIT trap clearing state on completion. - Crash path: planted a state file with a dead pid — `list` reports `idle` and the file is removed, rather than a run that never ends. - Verified the Rust listing script emits `running_since_epoch` non-null only for a live pid. - Two `set -e` traps found and fixed while testing: a `grep` matching nothing (empty log, the normal first seconds of a run) and an `ls` over an empty log dir both aborted `status` outright. - `cargo check`, `tsc --noEmit`, and the full Vitest suite pass — 352 tests, including 4 new AutomationTab tests covering the indicator, the disabled trigger, polling after a trigger, and *not* polling when idle.
jknapp added 1 commit 2026-08-12 13:14:21 +00:00
Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
9027fa9ad4
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.

The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.

Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp added 1 commit 2026-08-12 13:33:55 +00:00
Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
fa4940dd7d
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".

triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.

On top of that:

- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
  elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
  task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
  polls while anything is in flight — including the second or two between
  firing a run and the runner registering it, which is the exact window that
  used to read as dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp changed title from Stop the scheduler handing Claude root's HOME to Fix scheduled tasks failing to authenticate, and show when one is running 2026-08-12 13:34:12 +00:00
jknapp added 1 commit 2026-08-12 13:55:38 +00:00
Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
88f2e73474
jknapp merged commit 7265f55f27 into main 2026-08-12 13:55:54 +00:00
jknapp deleted branch fix/scheduler-home-clobber 2026-08-12 13:56:30 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#24