Fix scheduled tasks failing to authenticate, and show when one is running #24
+11
-1
@@ -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`
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@" ;;
|
||||
|
||||
@@ -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:-}"
|
||||
@@ -77,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) ==="
|
||||
|
||||
Reference in New Issue
Block a user