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
@@ -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