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.
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>
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 running2026-08-12 13:34:12 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Scheduled tasks have been failing with
Not logged in · Please run /logineven though the container's OAuth credential is present and valid.Cause
entrypoint.shsnapshots the environment into~/.claude/scheduler/.envso cron jobs get more than cron's minimal env. It runs as root, andHOMEwas in the capture list — so the file recordsHOME='/root'.triple-c-task-runnerthen sources that file withset -a, which overwrites theHOMEcron gave the job.claude -plooks for its credential under$HOME, there is no/root/.claude, and it exits 1.Logging still works because
SCHEDULER_DIRis 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:Reproduced and confirmed in a live container:
Fix
entrypoint.sh— dropHOMEfrom the captured set, and writeHOME='/home/claude'explicitly afterwards. Cron does still need aHOME; it just must not be root's.triple-c-task-runner— save and restoreHOMEacross thesource..envlives 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 -non both scripts.env -i HOME=/root ...:HOME='/home/claude'is written exactly once and every other captured var is unchanged..envcontainingHOME='/root':HOMEstays/home/claude, stays exported to children, and the other variables from the file still apply..envcorrection to a live container and ran a throwaway task end to end throughtriple-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 -pwrites 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-runnernow publishes~/.claude/scheduler/running/<id>.json(pid, start time, log path) and removes it from anEXITtrap.flockremains 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:
listgrows 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.runstreams the log instead of blocking silently, and refuses to double-start a running task.busytone + 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.Testing
listandstatusduring a real 30s run (running 12s, pid, log path), the double-run guard,runstreaming to the terminal,statuson an idle task showing its last result and exit code, and the EXIT trap clearing state on completion.listreportsidleand the file is removed, rather than a run that never ends.running_since_epochnon-null only for a live pid.set -etraps found and fixed while testing: agrepmatching nothing (empty log, the normal first seconds of a run) and anlsover an empty log dir both abortedstatusoutright.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.Stop the scheduler handing Claude root's HOMEto Fix scheduled tasks failing to authenticate, and show when one is running