From c6245cd3e71124b239aa1a3a4c577e884a70ba1b Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:50:07 +0200 Subject: [PATCH 1/2] feat(cockpit): per-row age/timeout-budget badge on live-progress rows (issue #116) Adds an "m / m timeout" badge into each live-progress row's Updated cell: elapsed = now - the row's own timestamp, budget = LOOP_DRIVER_TIMEOUT (loop-daemon.sh's run_driver() wall-clock cap, default 90m). The badge escalates muted -> warn (>=80% of budget) -> bad (>=100%) only while a .loop-driver-out.*.json file still exists in the state dir (loop-daemon.sh deletes it right after its driver exits, so existence is the "still running" signal) -- once the driver has exited the badge stays muted, since that row's clock has already stopped ticking against any enforced ceiling. cockpit.sh gathers the "still running" signal in its bash prelude (globs the real state dir, or reads a synthetic fixtures/loop-driver-running.json in --fixtures mode) and renders the badge server-side. cockpit-serve.sh's injected SSE client duplicates the SAME parseDurationToSeconds/threshold logic in JS (recomputed fresh on every full-page load) so a row upserted live via SSE never disagrees with a row rendered by the next full cockpit.sh re-render. The badge is folded into the existing "Updated" cell rather than added as a new table column, since cockpit.test.sh hardcodes this table's column count/colspan and is out of this change's module boundary. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/cockpit-serve.sh | 114 +++++++++++++++++++++++++++- .claude/scripts/cockpit.sh | 124 ++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/cockpit-serve.sh b/.claude/scripts/cockpit-serve.sh index 6b7759b..9fdd017 100755 --- a/.claude/scripts/cockpit-serve.sh +++ b/.claude/scripts/cockpit-serve.sh @@ -108,6 +108,23 @@ worktrees_root="${COCKPIT_SERVE_WORKTREES_ROOT:-$root}" # this at a synthetic temp file instead of the real, gitignored state dir. worker_tools_file="${CLAUDE_WORKER_TOOLS_FILE:-$root/.claude/state/worker-tools.jsonl}" +# Loop-driver timeout budget + "still running" signal (issue #116): same data +# sources cockpit.sh's own bash prelude gathers for its static render (see +# cockpit.sh's comment block on the same feature) — duplicated here (rather +# than shelling into cockpit.sh per request) because this value must be +# recomputed fresh on every page load, not just once at process startup. +# Fixtures mode points at the SAME /loop-driver-running.json seam +# cockpit.sh reads; live mode globs the real (gitignored) state dir at +# request time, honoring CLAUDE_DRIVER_OUT_DIR for parity. +if [ -n "$fixtures" ]; then + driver_running_fixture="$fixtures/loop-driver-running.json" + driver_out_dir="" +else + driver_running_fixture="" + driver_out_dir="${CLAUDE_DRIVER_OUT_DIR:-$root/.claude/state}" +fi +loop_driver_timeout="${LOOP_DRIVER_TIMEOUT:-90m}" + tmp_out="$(mktemp "${TMPDIR:-/tmp}/cockpit-serve.XXXXXX.html")" # NOTE: deliberately NO bash `trap ... EXIT` here. `exec` below REPLACES this # shell process image with node (same PID) — a bash-level EXIT trap @@ -128,6 +145,10 @@ COCKPIT_SERVE_GH_REFRESH="$gh_refresh" \ COCKPIT_SERVE_TMP_OUT="$tmp_out" \ COCKPIT_SERVE_WORKTREES_ROOT="$worktrees_root" \ CLAUDE_WORKER_TOOLS_FILE="$worker_tools_file" \ +COCKPIT_SERVE_DRIVER_RUNNING_FIXTURE="$driver_running_fixture" \ +COCKPIT_SERVE_DRIVER_OUT_DIR="$driver_out_dir" \ +COCKPIT_SERVE_LOOP_DRIVER_TIMEOUT="$loop_driver_timeout" \ +COCKPIT_SERVE_TIMEOUT_WARN_FRACTION="${COCKPIT_TIMEOUT_WARN_FRACTION:-0.8}" \ exec node - <<'NODE_SERVE' const http = require("http"); const fs = require("fs"); @@ -148,6 +169,58 @@ const WORKER_TOOLS_FILE = process.env.CLAUDE_WORKER_TOOLS_FILE || path.join(process.cwd(), ".claude", "state", "worker-tools.jsonl"); +// --------------------------------------------------------------------------- +// Live-progress age/timeout-budget badge (issue #116): the SSE upsert path +// below (upsertRow, inside clientScript()) applies the SAME formula/badge +// thresholds cockpit.sh's own renderLiveProgress()/timeoutBadge() use for the +// server-rendered rows, so a row upserted live never disagrees with a row +// that survives into the next full-page re-render. Duplicated rather than +// shared (these are two separate inline scripts with no shared module), same +// as every other per-row field this file already re-derives from raw event +// JSON independently of cockpit.sh (role/task/model/phase/lens/ts). +const DRIVER_RUNNING_FIXTURE = process.env.COCKPIT_SERVE_DRIVER_RUNNING_FIXTURE || ""; +const DRIVER_OUT_DIR = process.env.COCKPIT_SERVE_DRIVER_OUT_DIR || ""; +const LOOP_DRIVER_TIMEOUT_ENV = process.env.COCKPIT_SERVE_LOOP_DRIVER_TIMEOUT || "90m"; +const TIMEOUT_WARN_FRACTION = (() => { + const n = parseFloat(process.env.COCKPIT_SERVE_TIMEOUT_WARN_FRACTION); + return Number.isFinite(n) && n > 0 ? n : 0.8; +})(); + +// Mirrors cockpit.sh's own parseDurationToSeconds(): GNU-`timeout`-style +// duration grammar ("90m", "45s", "2h", "1.5d", or a bare number of seconds). +function parseDurationToSeconds(str, fallbackSeconds) { + const s = String(str == null ? "" : str).trim(); + const m = s.match(/^([0-9]*\.?[0-9]+)([smhd]?)$/); + if (!m) return fallbackSeconds; + const n = parseFloat(m[1]); + if (!Number.isFinite(n)) return fallbackSeconds; + const mult = { s: 1, m: 60, h: 3600, d: 86400 }[m[2] || "s"]; + return n * mult; +} +const DRIVER_BUDGET_SECONDS = parseDurationToSeconds(LOOP_DRIVER_TIMEOUT_ENV, 90 * 60); + +// "Still running" signal, re-checked on every full-page render (see +// clientScript() below, called fresh from handleIndex() on every request): +// fixtures mode reads the same /loop-driver-running.json seam cockpit.sh +// reads; live mode checks for any .loop-driver-out.*.json file in the state +// dir (loop-daemon.sh deletes it right after its driver exits). +function isDriverRunning() { + if (DRIVER_RUNNING_FIXTURE) { + try { + const j = JSON.parse(fs.readFileSync(DRIVER_RUNNING_FIXTURE, "utf8")); + return !!j.running; + } catch (e) { + return false; + } + } + if (!DRIVER_OUT_DIR) return false; + try { + return fs.readdirSync(DRIVER_OUT_DIR).some((f) => /^\.loop-driver-out\..*\.json$/.test(f)); + } catch (e) { + return false; + } +} + // Fix (issue #70, 3a-followup): TMP_OUT cleanup moved here from the now-dead // bash EXIT trap (see the shell comment above `exec node`, above) — this // fires on every node exit path, so the temp HTML file cockpit.sh renders @@ -196,10 +269,35 @@ function getHtml(force) { // via textContent, never innerHTML. // --------------------------------------------------------------------------- function clientScript() { + // Fresh per call (see injectClientScript(), invoked on every handleIndex() + // request): DRIVER_RUNNING is re-checked right now, so a page reload always + // reflects the CURRENT driver state, same freshness as the cached HTML + // itself gets on its own refresh cadence. + const driverRunning = isDriverRunning(); return `