Compare commits

...
Author SHA1 Message Date
jknapp 7265f55f27 Merge pull request 'Fix scheduled tasks failing to authenticate, and show when one is running' (#24) from fix/scheduler-home-clobber into main
Build Container / build-container (push) Successful in 1m52s
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m40s
Build App / build-linux (push) Successful in 7m30s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 17s
Reviewed-on: #24
2026-08-12 13:55:54 +00:00
jknapp 88f2e73474 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
2026-08-12 13:55:37 +00:00
shadow-testandClaude Opus 5 fa4940dd7d 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
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>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 9027fa9ad4 Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
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>
2026-08-12 06:13:59 -07:00
jknapp be37723c38 Merge pull request 'Sweep the snapshot commits recreation leaves behind' (#23) from sweep-orphaned-snapshots into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m44s
Build App / build-linux (push) Successful in 6m18s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 12s
Reviewed-on: #23
2026-08-12 02:06:24 +00:00
11 changed files with 420 additions and 19 deletions
+11 -1
View File
@@ -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`
+23 -2
View File
@@ -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<String>,
/// Whether a run is in flight right now, from the runner's state file in
/// `~/.claude/scheduler/running/<id>.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<String>,
}
/// 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<String>,
last_run_epoch: Option<i64>,
running_since_epoch: Option<i64>,
}
#[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())
+4 -3
View File
@@ -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."#;
@@ -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(<AutomationTab project={project} />);
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);
});
});
@@ -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<unknown>) => {
setBusyTaskId(taskId);
try {
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
{task.task_type}
</span>
{task.running && (
<StatusIndicator
tone="busy"
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
/>
)}
</div>
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
{task.at ?? task.schedule}
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
}
/>
<Button
disabled={busyTaskId === task.id}
disabled={busyTaskId === task.id || task.running}
onClick={() =>
withTask(task.id, "Run now", () =>
runScheduledTaskNow(project.id, task.id),
)
withTask(task.id, "Run now", async () => {
await runScheduledTaskNow(project.id, task.id);
setJustTriggered(Date.now());
})
}
>
Run now
{task.running ? "Running…" : "Run now"}
</Button>
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
Edit
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
created_at: null,
last_run: null,
next_run: null,
running: false,
running_since: null,
};
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
@@ -26,6 +26,21 @@ export function formatElapsed(ms: number): string {
return `${days}d ago`;
}
/** "for 42s" / "for 4m" / "for 1h 12m" elapsed phrasing for a run in flight.
* Seconds are kept below a minute because the first thing anyone wants from a
* freshly triggered run is evidence that it started at all. */
export function formatRunningFor(iso: string | null | undefined): string | null {
if (!iso) return null;
const started = Date.parse(iso);
if (Number.isNaN(started)) return null;
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000));
if (seconds < 60) return `for ${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `for ${minutes}m`;
const hours = Math.floor(minutes / 60);
return `for ${hours}h ${minutes % 60}m`;
}
/** Uptime phrasing for a known start timestamp. */
export function formatUptime(startedAtMs: number | undefined): string | null {
if (startedAtMs === undefined) return null;
+4
View File
@@ -388,6 +388,10 @@ export interface ScheduledTask {
last_run: string | null;
/** Known only for enabled one-shot tasks; cron is not evaluated. */
next_run: string | null;
/** A run is in flight right now (the runner's pid was verified live). */
running: boolean;
/** When that run started. Null unless `running`. */
running_since: string | null;
}
/** Mirrors Rust `ScheduleKind` which of the scheduler's two `add` flags to
+10 -1
View File
@@ -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"
+180 -6
View File
@@ -8,17 +8,59 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
# ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
}
generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
# checked rather than believed, and a state file whose process is gone is
# cleared here — otherwise one hard stop leaves a task reading as "running"
# forever, which is worse than no indicator at all.
run_state() {
local id="$1"
local state_file="${RUNNING_DIR}/${id}.json"
[ -f "$state_file" ] || return 1
local pid
pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
rm -f "$state_file"
return 1
fi
printf '%s\t%s\t%s\n' \
"$pid" \
"$(jq -r '.started_epoch // 0' "$state_file")" \
"$(jq -r '.log // ""' "$state_file")"
}
# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
elapsed_since() {
local start="$1" now delta
now=$(date +%s)
delta=$(( now - start ))
[ "$delta" -lt 0 ] && delta=0
if [ "$delta" -ge 3600 ]; then
printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
elif [ "$delta" -ge 60 ]; then
printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
else
printf '%ds' "$delta"
fi
}
# Reject a malformed cron expression at the point of entry.
#
# Without this an invalid schedule is written to a task file, and the next
@@ -85,8 +127,9 @@ Commands:
enable Enable a disabled task
disable Disable a task
list List all tasks
status Show which tasks are running right now
logs Show execution logs
run Manually trigger a task now
run Manually trigger a task now (streams its log)
notifications Show or clear completion notifications
Add options:
@@ -99,6 +142,10 @@ Add options:
Remove/Enable/Disable/Run options:
--id ID Task ID (required)
Status options:
--id ID Show one task, including its last result when idle
--watch, -w Refresh every 5s until the run finishes
Logs options:
--id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50)
@@ -313,8 +360,8 @@ cmd_disable() {
cmd_list() {
local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
@@ -333,12 +380,21 @@ cmd_list() {
display_schedule="at $at"
fi
local status state started
if state=$(run_state "$id"); then
started=$(printf '%s' "$state" | cut -f2)
status="running $(elapsed_since "$started")"
else
status="idle"
fi
# Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
"$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
done
if [ "$found" = "false" ]; then
@@ -346,6 +402,78 @@ cmd_list() {
fi
}
# Is anything running, and how far along is it?
#
# This is the command for the question "did my `run` do anything, or has it
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
# looks identical whether Claude is thinking or the run is dead.
cmd_status() {
local id="" watch=false
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--watch|-w) watch=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
while true; do
local any=false
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local tid
tid=$(jq -r '.id' "$task_file")
[ -z "$id" ] || [ "$tid" = "$id" ] || continue
local name state
name=$(jq -r '.name' "$task_file")
if state=$(run_state "$tid"); then
any=true
local pid started log
pid=$(printf '%s' "$state" | cut -f1)
started=$(printf '%s' "$state" | cut -f2)
log=$(printf '%s' "$state" | cut -f3)
echo "● RUNNING $name ($tid)"
echo " elapsed: $(elapsed_since "$started") pid: $pid"
echo " log: $log"
# `claude -p` writes its answer in one go at the end, so a log
# with only its header is the normal state of a healthy run —
# print the tail only when there is something to show, rather
# than an empty "last output:" that reads like a stall.
# `|| true` throughout: under `set -e` a grep matching nothing
# would otherwise abort the whole command.
local tail_out=""
if [ -f "$log" ]; then
tail_out=$({ grep -v '^===' "$log" || true; } \
| { grep -v '^$' || true; } | tail -n 3)
fi
if [ -n "$tail_out" ]; then
echo " last output:"
printf '%s\n' "$tail_out" | sed 's/^/ /'
fi
elif [ -n "$id" ]; then
echo "○ idle $name ($tid)"
local latest
latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
if [ -n "$latest" ]; then
echo " last run: $(basename "$latest" .log) $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
fi
fi
done
if [ "$any" = "false" ] && [ -z "$id" ]; then
echo "Nothing running."
fi
[ "$watch" = "true" ] || break
# Stop watching once the thing being watched has finished.
[ "$any" = "true" ] || break
sleep 5
echo ""
done
}
cmd_logs() {
local id="" tail_n=50
@@ -413,8 +541,53 @@ cmd_run() {
local name
name=$(jq -r '.name' "$task_file")
if run_state "$id" >/dev/null; then
echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
return 0
fi
echo "Manually triggering task '$name' ($id)..."
/usr/local/bin/triple-c-task-runner "$id"
# Run in the background and stream its log. A task can easily think for
# minutes, and the previous behaviour — block with no output until it is
# over — is indistinguishable from a hang.
/usr/local/bin/triple-c-task-runner "$id" &
local runner_pid=$!
local state="" waited=0
while [ "$waited" -lt 20 ]; do
if state=$(run_state "$id"); then
break
fi
kill -0 "$runner_pid" 2>/dev/null || break
sleep 0.5
waited=$(( waited + 1 ))
done
local log=""
[ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)
if [ -n "$log" ]; then
echo " log: $log"
echo " elsewhere: triple-c-scheduler status --id $id --watch"
echo ""
# --pid stops the follow when the runner exits, so this returns on its own.
tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
fi
local rc=0
wait "$runner_pid" || rc=$?
# A run short enough that its state file was never observed still deserves
# its output shown rather than swallowed.
if [ -z "$log" ]; then
local latest
latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
[ -n "$latest" ] && tail -n 20 "$latest"
fi
return $rc
}
cmd_notifications() {
@@ -464,6 +637,7 @@ case "$command" in
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
list) cmd_list ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;;
+30
View File
@@ -9,6 +9,7 @@ SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"
ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}"
@@ -34,11 +35,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 ────────────────────────────────────────────────────
@@ -69,6 +78,27 @@ mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Publish run state ───────────────────────────────────────────────────────
# A scheduled run is detached — cron has no terminal, and the app fires it as a
# detached exec — so without this there is no way to tell a task that is still
# thinking from one that died, and a long run reads as a stall. `list`, `status`
# and the app's Automation tab all read this file.
#
# flock above is what actually prevents overlapping runs; this is purely an
# observability record, which is why readers verify the pid rather than trust
# the file. The EXIT trap covers the crash paths (OOM, container stop, SIGTERM)
# that would otherwise leave a task looking like it had been running for days.
mkdir -p "$RUNNING_DIR"
RUN_STATE="${RUNNING_DIR}/${TASK_ID}.json"
trap 'rm -f "$RUN_STATE"' EXIT
jq -n \
--arg pid "$$" \
--arg started "$(date +%s)" \
--arg log "$LOG_FILE" \
--arg name "$TASK_NAME" \
'{pid: ($pid | tonumber), started_epoch: ($started | tonumber), log: $log, name: $name}' \
> "$RUN_STATE"
# ── Execute Claude agent ────────────────────────────────────────────────────
{
echo "=== Task: $TASK_NAME ($TASK_ID) ==="