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>
This commit is contained in:
2026-08-12 06:33:49 -07:00
co-authored by Claude Opus 5
parent 9027fa9ad4
commit fa4940dd7d
10 changed files with 402 additions and 18 deletions
+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