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) { } />