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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user