From 9027fa9ad4510abc8ff88753c019f816c76b4b5a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 12 Aug 2026 06:13:59 -0700 Subject: [PATCH 1/2] Stop the scheduler handing Claude root's HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- container/entrypoint.sh | 11 ++++++++++- container/triple-c-task-runner | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 1ce3701..1a9c0df 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -434,17 +434,26 @@ chown -R claude:claude "$SCHEDULER_DIR" cron # Save environment variables for cron jobs (cron runs with a minimal env) +# +# HOME is deliberately NOT captured here. This entrypoint runs as root, so the +# snapshot would record HOME=/root — and the task runner sources this file with +# `set -a`, which would overwrite the HOME cron gives the job. Claude Code then +# looks for its OAuth credential at /root/.claude/.credentials.json instead of +# /home/claude/.claude/.credentials.json and every scheduled task dies with +# "Not logged in · Please run /login". Cron still needs a HOME, so it is written +# explicitly below with the value the `claude` user actually has. ENV_FILE="$SCHEDULER_DIR/.env" : > "$ENV_FILE" env | while IFS='=' read -r key value; do case "$key" in - ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) + ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) # Escape single quotes in value and write as KEY='VALUE' escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" ;; esac done +printf "HOME='/home/claude'\n" >> "$ENV_FILE" chown claude:claude "$ENV_FILE" chmod 600 "$ENV_FILE" diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner index e7bf701..8d50f0f 100644 --- a/container/triple-c-task-runner +++ b/container/triple-c-task-runner @@ -34,11 +34,19 @@ if ! flock -n 200; then fi # ── Source saved environment ───────────────────────────────────────────────── +# The env file is a snapshot taken by the entrypoint, which runs as root. A +# snapshot written before the entrypoint stopped capturing HOME still carries +# HOME=/root, and `set -a` would apply it to `claude` below — which then finds no +# credential under /root/.claude and exits with "Not logged in". The env file +# lives on the home volume, so those stale copies outlive an image update until +# the container is restarted; keep our own HOME regardless of what it says. if [ -f "$ENV_FILE" ]; then + REAL_HOME="${HOME:-/home/claude}" set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a + HOME="$REAL_HOME" fi # ── Read task definition ──────────────────────────────────────────────────── From fa4940dd7dd8c79e3344dac3bb5dee09626cbde6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 12 Aug 2026 06:33:49 -0700 Subject: [PATCH 2/2] Say when a scheduled task is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- HOW-TO-USE.md | 12 +- .../src/commands/inspect_commands.rs | 25 ++- app/src-tauri/src/docker/container.rs | 7 +- .../projects/home/AutomationTab.test.tsx | 112 +++++++++++ .../projects/home/AutomationTab.tsx | 35 +++- .../projects/home/TaskEditorModal.test.tsx | 2 + app/src/components/projects/home/format.ts | 15 ++ app/src/lib/types.ts | 4 + container/triple-c-scheduler | 186 +++++++++++++++++- container/triple-c-task-runner | 22 +++ 10 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 app/src/components/projects/home/AutomationTab.test.tsx diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index d0e479e..31f5028 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -1139,13 +1139,23 @@ triple-c-scheduler list # List all tasks triple-c-scheduler enable --id abc123 # Enable a task triple-c-scheduler disable --id abc123 # Disable a task triple-c-scheduler remove --id abc123 # Delete a task -triple-c-scheduler run --id abc123 # Trigger a task immediately +triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log +triple-c-scheduler status # What is running right now, and for how long +triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes triple-c-scheduler logs --id abc123 # View logs for a task triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks) triple-c-scheduler notifications # View completion notifications triple-c-scheduler notifications --clear # Clear notifications ``` +`list` carries a status column, and the Automation tab marks a task **Running** with +its elapsed time, so a triggered run is visible rather than silent. + +Note that a log which has stopped growing is not evidence of a stall: `claude -p` +writes its answer in one go when it finishes, so a healthy run shows nothing but its +header for as long as it is thinking. `status` is what distinguishes a slow run from +a dead one — it reports the run only while the runner's process is genuinely alive. + ### Cron Schedule Format Standard 5-field cron: `minute hour day-of-month month day-of-week` diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs index aacc77e..68749f3 100644 --- a/app/src-tauri/src/commands/inspect_commands.rs +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -164,6 +164,11 @@ pub struct ScheduledTask { /// Only known for enabled one-shot tasks (their `at` time). Recurring cron /// expressions are not evaluated here. pub next_run: Option, + /// Whether a run is in flight right now, from the runner's state file in + /// `~/.claude/scheduler/running/.json` with its pid verified live. + pub running: bool, + /// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`. + pub running_since: Option, } /// A completion notice written by `triple-c-task-runner` after a task ran. @@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null set -u TASKS="$HOME/.claude/scheduler/tasks" LOGS="$HOME/.claude/scheduler/logs" +RUNNING="$HOME/.claude/scheduler/running" [ -d "$TASKS" ] || { echo '[]'; exit 0; } for f in "$TASKS"/*.json; do [ -f "$f" ] || continue id=$(jq -r '.id // ""' "$f") || continue [ -n "$id" ] || id=$(basename "$f" .json) last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1) - jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{ + # Live-run state. The pid is checked, not trusted: a container stopped + # mid-run cannot fire the runner's cleanup trap, and a task stuck on + # "running" forever is a worse lie than showing nothing. + started="" + state="$RUNNING/$id.json" + if [ -f "$state" ]; then + pid=$(jq -r '.pid // empty' "$state") + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + started=$(jq -r '.started_epoch // empty' "$state") + fi + fi + jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{ id: (if (.id // "") == "" then $fallback_id else .id end), name: (.name // ""), prompt: (.prompt // ""), @@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do enabled: (.enabled == true), working_dir: (.working_dir // "/workspace"), created_at: (.created_at // null), - last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end) + last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end), + running_since_epoch: (if $started == "" then null else ($started | tonumber) end) }' "$f" done | jq -s 'sort_by(.name, .id)' "#; @@ -673,6 +691,7 @@ struct RawScheduledTask { working_dir: String, created_at: Option, last_run_epoch: Option, + running_since_epoch: Option, } #[derive(Debug, Deserialize)] @@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks( created_at: t.created_at, last_run: t.last_run_epoch.map(epoch_to_iso), next_run, + running: t.running_since_epoch.is_some(), + running_since: t.running_since_epoch.map(epoch_to_iso), } }) .collect()) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index f6698f0..325c3a7 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -18,11 +18,12 @@ This container supports scheduled tasks via `triple-c-scheduler`. You can set up ### Commands - `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task - `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task -- `triple-c-scheduler list` — List all scheduled tasks +- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column - `triple-c-scheduler remove --id ID` — Remove a task - `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks +- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long - `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs -- `triple-c-scheduler run --id ID` — Manually trigger a task immediately +- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log) - `triple-c-scheduler notifications [--clear]` — View or clear completion notifications ### Cron format @@ -36,7 +37,7 @@ Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically re Use `--working-dir /workspace/project` to set where the task runs (default: /workspace). ### Checking results -After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`. +While a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`. ### Timezone Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#; diff --git a/app/src/components/projects/home/AutomationTab.test.tsx b/app/src/components/projects/home/AutomationTab.test.tsx new file mode 100644 index 0000000..8ad36f6 --- /dev/null +++ b/app/src/components/projects/home/AutomationTab.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import AutomationTab from "./AutomationTab"; +import type { Project, ScheduledTask } from "../../../lib/types"; + +const listScheduledTasks = vi.fn(async () => tasks); +const getSchedulerNotifications = vi.fn(async () => []); +const runScheduledTaskNow = vi.fn(async () => "started"); +const pushToast = vi.fn(); + +vi.mock("../../../lib/tauri-commands", () => ({ + listScheduledTasks: () => listScheduledTasks(), + getSchedulerNotifications: () => getSchedulerNotifications(), + runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t), + clearSchedulerNotifications: vi.fn(async () => {}), + getScheduledTaskLog: vi.fn(async () => ""), + removeScheduledTask: vi.fn(async () => {}), + setScheduledTaskEnabled: vi.fn(async () => {}), +})); + +vi.mock("../../../store/appState", () => ({ + useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }), +})); + +const project = { id: "p1", name: "api", status: "running" } as unknown as Project; + +const baseTask: ScheduledTask = { + id: "a1b2c3d4", + name: "nightly", + prompt: "Run the suite", + schedule: "0 3 * * *", + task_type: "recurring", + at: null, + enabled: true, + working_dir: "/workspace", + created_at: null, + last_run: null, + next_run: null, + running: false, + running_since: null, +}; + +let tasks: ScheduledTask[] = []; + +async function renderTab() { + render(); + await act(async () => { + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + tasks = [baseTask]; + listScheduledTasks.mockClear(); + runScheduledTaskNow.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("AutomationTab run state", () => { + it("offers Run now for an idle task and says nothing about running", async () => { + await renderTab(); + expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled(); + expect(screen.queryByText(/Running/)).toBeNull(); + }); + + it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => { + const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString(); + tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }]; + await renderTab(); + + // The whole point: a detached run is visible rather than silent. + expect(screen.getByText(/Running for 1m/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled(); + }); + + it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => { + await renderTab(); + const callsAfterLoad = listScheduledTasks.mock.calls.length; + + // The runner needs a moment to write its state file; until then the task + // still reads as idle, which is exactly the window that used to look dead. + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Run now" })); + await Promise.resolve(); + }); + expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4"); + + tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }]; + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + + expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad); + expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled(); + }); + + it("stops polling once nothing is running", async () => { + await renderTab(); + // No trigger, nothing running: the interval must not be armed at all. + const before = listScheduledTasks.mock.calls.length; + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + expect(listScheduledTasks.mock.calls.length).toBe(before); + }); +}); diff --git a/app/src/components/projects/home/AutomationTab.tsx b/app/src/components/projects/home/AutomationTab.tsx index f481cb7..e6172f7 100644 --- a/app/src/components/projects/home/AutomationTab.tsx +++ b/app/src/components/projects/home/AutomationTab.tsx @@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle"; import Modal from "../../ui/Modal"; import StatusIndicator from "../../ui/StatusIndicator"; import TaskEditorModal from "./TaskEditorModal"; -import { formatAge } from "./format"; +import { formatAge, formatRunningFor } from "./format"; interface Props { project: Project; @@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) { useEffect(load, [load]); + // A task in flight is the one state this view cannot sit still for: runs are + // detached, so without polling "Run now" looks like it did nothing until the + // user reaches for Refresh. Polling stops as soon as nothing is running. + // + // `justTriggered` covers the gap between firing a run and the runner writing + // its state file — a second or two in which the task still reads as idle, and + // where giving up on polling would reproduce the exact silence this fixes. + const anyTaskRunning = tasks.some((t) => t.running); + const [justTriggered, setJustTriggered] = useState(0); + useEffect(() => { + if (!running) return; + if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return; + const timer = setInterval(load, anyTaskRunning ? 5000 : 1500); + return () => clearInterval(timer); + }, [running, anyTaskRunning, justTriggered, load]); + const withTask = async (taskId: string, label: string, fn: () => Promise) => { setBusyTaskId(taskId); try { @@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) { {task.task_type} + {task.running && ( + + )}
{task.at ?? task.schedule} @@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) { } />