diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index 0719316..2fa2a9a 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -9,6 +9,12 @@ # no persistent server, no watch daemon (re-run this script, or wrap it in # `watch -n 30 bash .claude/scripts/cockpit.sh`). # +# Issue #85 adds a "Loop health" panel, sourced from loop-tick.sh's tick +# record log (see loop-tick.sh's write_tick_record): the last tick's verdict, +# the current cadence (FAST/WATCH/IDLE), the full verdict history (newest +# first), and a STALLED banner if no tick has landed in over 2x the cadence's +# expected interval (FAST=60s -> 120s, WATCH=300s -> 600s, IDLE=900s -> 1800s). +# # Usage: # cockpit.sh [--fixtures ] [output-path] # cockpit.sh --parse-blocking @@ -22,8 +28,9 @@ # like `gh issue|pr list --json ...` output) instead of calling gh at all. # This is the offline seam cockpit.test.sh uses — no live gh/network in tests. # In this mode, the live-progress panel also reads /events.jsonl (if -# present; missing = "no active workers") instead of the real event log, so -# tests never touch .claude/state/. +# present; missing = "no active workers") instead of the real event log, and +# the loop-health panel likewise reads /loop-ticks.jsonl (if present; +# missing = "loop not armed"), so tests never touch .claude/state/. # # Degrades gracefully: if a bot-gh.sh call fails (no network / no gh auth), # that section renders an "unavailable (gh/network)" placeholder instead of @@ -212,6 +219,20 @@ else fi if [ -f "$events_file" ]; then cp "$events_file" "$tmpdir/events.jsonl"; else : >"$tmpdir/events.jsonl"; fi +# ---- loop tick records (issue #85, "Loop health" panel) -------------------- +# Same offline seam as the events.jsonl block above: fixtures mode reads +# /loop-ticks.jsonl (if present); otherwise honors CLAUDE_TICKS_FILE for +# parity with loop-tick.sh's own override, defaulting to the same gitignored +# .claude/state/loop-ticks.jsonl. A missing/empty log just means the loop has +# never ticked (or isn't armed yet) — rendered as a placeholder below, never +# an error. +if [ -n "$fixtures" ]; then + ticks_file="$fixtures/loop-ticks.jsonl" +else + ticks_file="${CLAUDE_TICKS_FILE:-$root/.claude/state/loop-ticks.jsonl}" +fi +if [ -f "$ticks_file" ]; then cp "$ticks_file" "$tmpdir/loop-ticks.jsonl"; else : >"$tmpdir/loop-ticks.jsonl"; fi + # ---- active worktrees ----------------------------------------------------------- node -e ' const fs = require("fs"); @@ -233,6 +254,8 @@ COCKPIT_OUT="$out" \ COCKPIT_ISSUES_UNAVAILABLE="$issues_unavailable" \ COCKPIT_PRS_UNAVAILABLE="$prs_unavailable" \ COCKPIT_GATES_REF="$gates_ref" \ +COCKPIT_NOW="${COCKPIT_NOW:-}" \ +COCKPIT_VERDICT_HISTORY_N="${COCKPIT_VERDICT_HISTORY_N:-10}" \ node - <<'NODE_RENDER' const fs = require("fs"); const path = require("path"); @@ -271,6 +294,26 @@ function readEvents() { } const events = readEvents(); +// Loop tick records (issue #85): JSONL, one object per line, appended by +// loop-tick.sh's write_tick_record — schema {ts, verdict, cadence, action, +// issue, pr}. Same tolerate-and-skip contract as readEvents() above: a +// blank/malformed line must never crash the whole render. +function readTicks() { + let text = ""; + try { text = fs.readFileSync(path.join(tmpdir, "loop-ticks.jsonl"), "utf8"); } catch (e) { return []; } + const ticks = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + if (obj && typeof obj === "object" && !Array.isArray(obj)) ticks.push(obj); + } catch (e) { /* skip malformed line */ } + } + return ticks; +} +const ticks = readTicks(); + function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") @@ -353,6 +396,61 @@ function renderLiveProgress() { return html; } +// ---- Loop health section (issue #85) --------------------------------------- +// Sourced from loop-tick.sh's tick record log (loop-ticks.jsonl, one line per +// firing, file order == append order == chronological). No records at all +// (missing file, or a file with zero valid lines) means the loop has never +// ticked in this environment -- rendered as "loop not armed", never a crash. +// Otherwise: the last tick's ts/verdict, the current cadence, a STALLED +// banner when now - lastTick exceeds 2x the cadence's expected interval, and +// the last N verdict lines, newest-first (N is bounded, NOT the full +// potentially ~2000-row retained log -- see COCKPIT_VERDICT_HISTORY_N below). +const CADENCE_INTERVAL_SECONDS = { FAST: 60, WATCH: 300, IDLE: 900 }; +const nowMs = process.env.COCKPIT_NOW ? Date.parse(process.env.COCKPIT_NOW) : Date.now(); +// Verdict-history table depth: "the last N verdict lines, newest first" +// (issue #85). Overridable for testability, consistent with the +// COCKPIT_NOW/CLAUDE_TICKS_FILE override style used elsewhere in this file. +// Falls back to 10 if unset/non-numeric/non-positive. +const VERDICT_HISTORY_N = (() => { + const n = parseInt(process.env.COCKPIT_VERDICT_HISTORY_N, 10); + return Number.isFinite(n) && n > 0 ? n : 10; +})(); +function renderLoopHealth() { + let html = `

Loop health

`; + if (ticks.length === 0) { + html += `

loop not armed

`; + return html; + } + const last = ticks[ticks.length - 1]; // file order = append order -> last line = most recent tick + const cadence = last.cadence != null ? String(last.cadence) : ""; + const intervalSec = CADENCE_INTERVAL_SECONDS[cadence]; + + html += `

Last tick: ${esc(last.ts)} · verdict ${esc(last.verdict)}

`; + html += `

Cadence: ${esc(cadence || "(unknown)")}`; + if (intervalSec) html += ` (every ${intervalSec}s)`; + html += `

`; + + const lastMs = Date.parse(last.ts); + let stalled = false; + if (intervalSec && Number.isFinite(lastMs) && Number.isFinite(nowMs)) { + stalled = nowMs - lastMs > intervalSec * 2 * 1000; + } + if (stalled) { + html += `

STALLED — no tick in over ${intervalSec * 2}s (cadence ${esc(cadence)})

`; + } + + html += ``; + const historyStop = Math.max(0, ticks.length - VERDICT_HISTORY_N); + for (let i = ticks.length - 1; i >= historyStop; i--) { + const t = ticks[i]; + html += ``; + } + html += `
TimeVerdictCadence
${esc(t.ts)}${esc(t.verdict)}${esc(t.cadence)}
`; + + html += ``; + return html; +} + // ---- Issues section: group by module label, parse blocking graph per issue ---- function renderIssues() { if (issuesUnavailable) { @@ -479,7 +577,7 @@ function renderWorktrees() { return html; } -const generatedAt = new Date().toISOString(); +const generatedAt = Number.isFinite(nowMs) ? new Date(nowMs).toISOString() : new Date().toISOString(); // Dark-theme stable marker (issue #69): the `data-theme="dark"` attribute // below is the CONTRACT a test/consumer can grep for to confirm the default // theme. The tiny \"","model":"sonnet","task":"52c","phase":"scoped","lens":"","detail":""} EOF +# Loop tick fixture (issue #85, "Loop health" panel): two ticks, file order = +# chronological, so the LAST line (FAST/advance) is the most recent tick and +# must render as "Last tick" while BOTH rows appear in the verdict history, +# newest first. +cat > "$work/fixtures/loop-ticks.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","verdict":"action=none","cadence":"IDLE","action":"none","issue":"","pr":""} +{"ts":"2026-01-01T00:15:00Z","verdict":"action=advance issue=7","cadence":"FAST","action":"advance","issue":"7","pr":""} +EOF html="$work/cockpit.html" -bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log" +# COCKPIT_NOW pins "now" to 90s after the last tick above -- inside FAST's +# 120s stall threshold, so this run must NOT show the STALLED banner (that +# path is exercised separately in section 2b below). +COCKPIT_NOW="2026-01-01T00:16:30Z" bash "$cockpit" --fixtures "$work/fixtures" "$html" >"$work/stdout.log" 2>"$work/stderr.log" rc=$? check "generator exits 0 on fixture run" [ "$rc" -eq 0 ] check "generator prints the output path" grep -qF "$html" "$work/stdout.log" @@ -208,6 +223,87 @@ check "dark theme marker present by default" grep -qF 'data-theme="dark"' "$html check "theme-toggle button present" grep -q 'id="theme-toggle"' "$html" check "issue rows carry data-module for the client-side filter" grep -q 'data-module="module:harness"' "$html" +# Loop health panel (issue #85): last tick, cadence, verdict history newest +# first, and NO stall banner (COCKPIT_NOW above is only 90s past the last +# tick, inside FAST's 120s threshold). +check "loop health section present" grep -q '
2026-01-01T00:15:00Z · verdict action=advance issue=7' "$html" +check "current cadence (FAST) is rendered" grep -qF 'FAST' "$html" +check "no STALLED banner when the last tick is within the cadence threshold" bash -c '! grep -q "STALLED" "$1"' _ "$html" +check "verdict history renders BOTH ticks, newest first" node -e ' + const fs = require("fs"); + const html = fs.readFileSync(process.argv[1], "utf8"); + const m = html.match(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("loop-health section not found"); + const rows = [...m[0].matchAll(/([^<]*)<\/td>([^<]*)<\/code><\/td>/g)].map((r) => r[2]); + const want = ["action=advance issue=7", "action=none"]; + if (JSON.stringify(rows) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want)); + } +' "$html" + +# --------------------------------------------------------------------------- +# 2b. Loop health STALLED banner: a last tick far older than 2x its cadence's +# expected interval must render the STALLED banner. Reuses the SAME +# fixtures dir (issues/prs/events unrelated) but pins COCKPIT_NOW well +# past the FAST tick's 120s threshold. +# --------------------------------------------------------------------------- +html_stalled="$work/cockpit-stalled.html" +COCKPIT_NOW="2026-01-01T01:00:00Z" bash "$cockpit" --fixtures "$work/fixtures" "$html_stalled" >/dev/null 2>"$work/stderr-stalled.log" +check "STALLED banner renders once the last tick exceeds 2x its cadence interval" grep -qF 'STALLED — no tick in over 120s (cadence FAST)' "$html_stalled" + +# --------------------------------------------------------------------------- +# 2c. Verdict-history cap (review fix for issue #85): the panel must show only +# the last N verdict lines, newest first -- NOT every retained tick (the +# ticks file itself may hold up to LOOP_TICKS_MAX_LINES/2000 rows). Uses a +# dedicated fixtures dir with 5 DISTINGUISHABLE ticks (unique issue= per +# line, mirroring the loop-tick.test.sh rotation fix) and +# COCKPIT_VERDICT_HISTORY_N=3 so the cap is exercised deterministically +# without needing a huge fixture. +# --------------------------------------------------------------------------- +mkdir -p "$work/fixtures-history" +echo "[]" >"$work/fixtures-history/issues.json" +echo "[]" >"$work/fixtures-history/prs.json" +: >"$work/fixtures-history/events.jsonl" +cat > "$work/fixtures-history/loop-ticks.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","verdict":"action=advance issue=1","cadence":"FAST","action":"advance","issue":"1","pr":""} +{"ts":"2026-01-01T00:01:00Z","verdict":"action=advance issue=2","cadence":"FAST","action":"advance","issue":"2","pr":""} +{"ts":"2026-01-01T00:02:00Z","verdict":"action=advance issue=3","cadence":"FAST","action":"advance","issue":"3","pr":""} +{"ts":"2026-01-01T00:03:00Z","verdict":"action=advance issue=4","cadence":"FAST","action":"advance","issue":"4","pr":""} +{"ts":"2026-01-01T00:04:00Z","verdict":"action=advance issue=5","cadence":"FAST","action":"advance","issue":"5","pr":""} +EOF +html_history="$work/cockpit-history.html" +COCKPIT_NOW="2026-01-01T00:04:30Z" COCKPIT_VERDICT_HISTORY_N=3 bash "$cockpit" --fixtures "$work/fixtures-history" "$html_history" >/dev/null 2>"$work/stderr-history.log" +check "verdict-history cap: last tick is still the most recent (issue=5)" grep -qF '2026-01-01T00:04:00Z · verdict action=advance issue=5' "$html_history" +check "verdict-history cap: table renders exactly COCKPIT_VERDICT_HISTORY_N=3 rows, newest first" node -e ' + const fs = require("fs"); + const html = fs.readFileSync(process.argv[1], "utf8"); + const m = html.match(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("loop-health section not found"); + const rows = [...m[0].matchAll(/([^<]*)<\/td>([^<]*)<\/code><\/td>/g)].map((r) => r[2]); + const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3"]; + if (JSON.stringify(rows) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want)); + } +' "$html_history" + +# Default (COCKPIT_VERDICT_HISTORY_N unset) with only 5 ticks retained must +# still render all 5 -- the default cap (10) must not truncate BELOW what's +# actually there. +html_history_default="$work/cockpit-history-default.html" +COCKPIT_NOW="2026-01-01T00:04:30Z" bash "$cockpit" --fixtures "$work/fixtures-history" "$html_history_default" >/dev/null 2>"$work/stderr-history-default.log" +check "verdict-history default cap (10) does not truncate a shorter (5-tick) history" node -e ' + const fs = require("fs"); + const html = fs.readFileSync(process.argv[1], "utf8"); + const m = html.match(/
[\s\S]*?<\/section>/); + if (!m) throw new Error("loop-health section not found"); + const rows = [...m[0].matchAll(/([^<]*)<\/td>([^<]*)<\/code><\/td>/g)].map((r) => r[2]); + const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3", "action=advance issue=2", "action=advance issue=1"]; + if (JSON.stringify(rows) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want)); + } +' "$html_history_default" + # --------------------------------------------------------------------------- # 3. GATES_FILE override is honored (self-host adapter), still with fixtures # (no gh/network either way). @@ -227,16 +323,19 @@ exit 1 EOF chmod +x "$fake_gh" html_unavail="$work/cockpit-unavail.html" -# CLAUDE_EVENTS_FILE points at a guaranteed-missing path so this run is fully -# offline/deterministic (never touches the real, gitignored event log) and -# doubles as the "no events file at all" -> "no active workers" assertion. -COCKPIT_GH_BIN="$fake_gh" CLAUDE_EVENTS_FILE="$work/no-such-events.jsonl" bash "$cockpit" "$html_unavail" >/dev/null 2>"$work/stderr-unavail.log" +# CLAUDE_EVENTS_FILE/CLAUDE_TICKS_FILE point at guaranteed-missing paths so +# this run is fully offline/deterministic (never touches the real, +# gitignored logs) and doubles as the "no log at all" degrade assertions for +# both the live-progress panel ("no active workers") and the loop-health +# panel ("loop not armed", issue #85) -- neither must crash the render. +COCKPIT_GH_BIN="$fake_gh" CLAUDE_EVENTS_FILE="$work/no-such-events.jsonl" CLAUDE_TICKS_FILE="$work/no-such-ticks.jsonl" bash "$cockpit" "$html_unavail" >/dev/null 2>"$work/stderr-unavail.log" rc_unavail=$? check "generator still exits 0 when gh is unavailable" [ "$rc_unavail" -eq 0 ] check "issues section shows unavailable placeholder" grep -q '

Open issues

unavailable (gh/network)

' "$html_unavail" check "PRs section shows unavailable placeholder" grep -q '

Open PRs

unavailable (gh/network)

' "$html_unavail" check "routing/worktrees sections still render (no crash) despite gh failure" bash -c 'grep -q "routing" "$1" && grep -q "worktrees" "$1"' _ "$html_unavail" check "missing events file renders 'no active workers' placeholder" grep -q '

Live worker progress

no active workers

' "$html_unavail" +check "missing loop-ticks log renders 'loop not armed' placeholder, no crash" grep -qF '

Loop health

loop not armed

' "$html_unavail" # --------------------------------------------------------------------------- # 5. Serve mode (cockpit-serve.sh, issue #69): dashboard over HTTP + SSE live diff --git a/.claude/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh index c44de55..8aef437 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -87,6 +87,88 @@ set -uo pipefail gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +# --------------------------------------------------------------------------- +# Tick record (issue #85): append ONE record per firing to +# .claude/state/loop-ticks.jsonl, so the cockpit's "Loop health" panel can +# show the last tick, current cadence, verdict history, and detect a stalled +# loop. Mirrors log-event.sh's EXACT pattern: the JSON line is built with +# `node` (never hand-rolled string interpolation) so values are safely +# escaped, and the file is rotated to the last N lines via temp-file + atomic +# `mv` (crash-safe). +# +# CRITICAL INVARIANT: this must NEVER print to stdout and must NEVER change +# this script's exit status or verdict -- the verdict line printed at the end +# of this script MUST remain the LAST line of stdout (the daemon/tick parser +# reads the last line). Best-effort/never-break, exactly like log-event.sh: +# every step below is guarded so a failure here can never affect the tick. +# +# Log file: defaults to /.claude/state/loop-ticks.jsonl. Override with +# CLAUDE_TICKS_FILE= (used by tests to point at a temp file +# instead of the real, gitignored state dir). Override the rotation cap with +# LOOP_TICKS_MAX_LINES (default 2000), matching log-event.sh's +# EVENTS_MAX_LINES. +write_tick_record() { + local verdict="$1" cadence="$2" + local ticks_file="${CLAUDE_TICKS_FILE:-$root/.claude/state/loop-ticks.jsonl}" + local max_lines="${LOOP_TICKS_MAX_LINES:-2000}" + local action="" issue="" pr="" + case "$verdict" in + "action=advance issue="*) action="advance"; issue="${verdict#action=advance issue=}" ;; + "action=feedback pr="*) action="feedback"; pr="${verdict#action=feedback pr=}" ;; + "action=none") action="none" ;; + *) + action="${verdict#action=}" + action="${action%% *}" + ;; + esac + + mkdir -p "$(dirname "$ticks_file")" 2>/dev/null || return 0 + + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || ts="" + + CLAUDE_TICK_TS="$ts" \ + CLAUDE_TICK_VERDICT="$verdict" \ + CLAUDE_TICK_CADENCE="$cadence" \ + CLAUDE_TICK_ACTION="$action" \ + CLAUDE_TICK_ISSUE="$issue" \ + CLAUDE_TICK_PR="$pr" \ + node -e ' + const line = JSON.stringify({ + ts: process.env.CLAUDE_TICK_TS || "", + verdict: process.env.CLAUDE_TICK_VERDICT || "", + cadence: process.env.CLAUDE_TICK_CADENCE || "", + action: process.env.CLAUDE_TICK_ACTION || "", + issue: process.env.CLAUDE_TICK_ISSUE || "", + pr: process.env.CLAUDE_TICK_PR || "", + }); + process.stdout.write(line + "\n"); + ' >>"$ticks_file" 2>/dev/null || return 0 + + # ---- rotation: cap to the last $max_lines lines, atomically ------------- + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const max = parseInt(process.argv[2], 10); + const tmp = process.argv[3]; + try { + if (!Number.isFinite(max) || max <= 0) process.exit(0); + const text = fs.readFileSync(file, "utf8"); + const lines = text.split("\n"); + // drop a single trailing empty string from the final newline, if present + if (lines.length && lines[lines.length - 1] === "") lines.pop(); + if (lines.length <= max) process.exit(0); + const kept = lines.slice(lines.length - max); + fs.writeFileSync(tmp, kept.join("\n") + "\n"); + fs.renameSync(tmp, file); + } catch (e) { + process.exit(0); + } + ' "$ticks_file" "$max_lines" "$ticks_file.tmp.$$" 2>/dev/null + + return 0 +} + echo "=== 1/4 loop-census.sh ===" census_out="$(bash "$script_dir/loop-census.sh" "$repo")" printf '%s\n' "$census_out" @@ -107,6 +189,9 @@ echo "=== verdict ===" advance_ready="$(printf '%s\n' "$census_out" | sed -n 's/^advance_ready=//p' | tail -1)" advance_ready="${advance_ready:-none}" in_flight_issues="$(printf '%s\n' "$census_out" | sed -n 's/^in_flight=//p')" +# Cadence (FAST/WATCH/IDLE), for the tick record (issue #85) -- census emits +# e.g. "cadence=FAST cron=* * * * *"; keep only the leading token. +cadence="$(printf '%s\n' "$census_out" | sed -n 's/^cadence=\([A-Za-z]*\).*/\1/p' | tail -1)" # --- Parse pr-feedback.sh's TSV (num, branch, reviewer, changes_requested_at) -- # Lowest-numbered PR wins when several need feedback addressed. @@ -176,21 +261,33 @@ if [ -n "$lock_issue" ]; then fi # --- Decide the verdict ----------------------------------------------------- +# The verdict string is captured into a variable (rather than echoed inline) +# so it can ALSO be persisted to the tick log below without disturbing the +# invariant that the verdict line is the LAST line of stdout. +verdict="" if [ -n "$feedback_pr" ]; then - echo "action=feedback pr=$feedback_pr" + verdict="action=feedback pr=$feedback_pr" elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ]; then if printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready"; then echo "# advance refused: issue=$advance_ready is in_flight (a feat/issue-$advance_ready-* branch already exists with no open PR)" - echo "action=none" + verdict="action=none" elif [ "$lock_issue" = "$advance_ready" ]; then echo "# advance refused: spawn lock already held for issue=$advance_ready ($(cat "$lock_file" 2>/dev/null))" - echo "action=none" + verdict="action=none" else tmp="$(mktemp "$state_dir/.loop-advance.lock.XXXXXX")" printf 'issue=%s ts=%s\n' "$advance_ready" "$(date -u +%FT%TZ)" > "$tmp" mv -f "$tmp" "$lock_file" - echo "action=advance issue=$advance_ready" + verdict="action=advance issue=$advance_ready" fi else - echo "action=none" + verdict="action=none" fi + +echo "$verdict" + +# Persist the tick record (issue #85) AFTER the verdict has been echoed, and +# writing to the FILE ONLY -- never stdout -- so the verdict line above stays +# the last line of this script's stdout. Best-effort: never allowed to affect +# the exit status set below. +write_tick_record "$verdict" "$cadence" || true diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index 3dde40e..5cd16e3 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -240,6 +240,144 @@ advances=0 check "scenario 9 (concurrent ticks): exactly ONE of two overlapping ticks advances issue=42" bash -c '[ "$1" -eq 1 ]' _ "$advances" check "scenario 9: the other tick backs off with action=none instead of double-advancing" bash -c '[ "$1" = "action=none" ] || [ "$2" = "action=none" ]' _ "$verdictA" "$verdictB" +# --------------------------------------------------------------------------- +# 10. Tick record (issue #85): every run appends exactly ONE JSONL line to +# CLAUDE_TICKS_FILE with the expected fields, and — critically — writing +# that record never disturbs the invariant that the verdict stays the +# LAST line of stdout (the daemon/tick parser reads the last line). +# --------------------------------------------------------------------------- +dir10="$(new_fixture scenario10 'open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=55 branch=none title=Tick record thing +advance_ready=55 +cadence=FAST cron=* * * * *' '')" +ticks10="$work/scenario10-ticks.jsonl" +out10="$(CLAUDE_TICKS_FILE="$ticks10" run_tick "$dir10")" +check "scenario 10: verdict is still the LAST stdout line when tick recording is on" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=55" ]' _ "$out10" +check "scenario 10: tick record file has exactly 1 line" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 1 ]' _ "$ticks10" +check "scenario 10: tick record is valid JSON with the expected fields" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.verdict !== "action=advance issue=55") throw new Error("verdict mismatch: " + JSON.stringify(obj)); + if (obj.action !== "advance") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.issue !== "55") throw new Error("issue mismatch: " + JSON.stringify(obj)); + if (obj.cadence !== "FAST") throw new Error("cadence mismatch: " + JSON.stringify(obj)); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(obj.ts)) throw new Error("ts not ISO-8601 UTC: " + obj.ts); +' "$ticks10" + +# action=none tick record: issue/pr must serialize as empty strings. +dir11="$(new_fixture scenario11 'open_prs=0 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=IDLE cron=*/15 * * * *' '')" +ticks11="$work/scenario11-ticks.jsonl" +out11="$(CLAUDE_TICKS_FILE="$ticks11" run_tick "$dir11")" +check "scenario 11: verdict is still the LAST stdout line for action=none" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out11" +check "scenario 11: action=none tick record parses issue/pr as empty" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.action !== "none") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.issue !== "" || obj.pr !== "") throw new Error("expected empty issue/pr, got " + JSON.stringify(obj)); +' "$ticks11" + +# action=feedback tick record: pr number captured, cadence round-trips. +dir12="$(new_fixture scenario12 'open_prs=0 +feedback_prs=1 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' "$(printf '3\tfeat/issue-3-x\towner\t2026-01-01T00:00:00Z')")" +ticks12="$work/scenario12-ticks.jsonl" +out12="$(CLAUDE_TICKS_FILE="$ticks12" run_tick "$dir12")" +check "scenario 12: verdict is still the LAST stdout line for action=feedback" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=feedback pr=3" ]' _ "$out12" +check "scenario 12: action=feedback tick record captures pr number and cadence" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.action !== "feedback") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.pr !== "3") throw new Error("pr mismatch: " + JSON.stringify(obj)); + if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); +' "$ticks12" + +# --------------------------------------------------------------------------- +# 11. Rotation: LOOP_TICKS_MAX_LINES caps the tick log to the last N lines +# across repeated ticks (mirrors log-event.sh's rotation, log-event.test.sh +# lines ~87-115). +# +# Each iteration below gets a DISTINCT advance_ready/issue so every +# retained JSONL line is byte-DIFFERENT (not the same static fixture +# replayed N times) -- otherwise line-count + JSON-validity checks alone +# cannot tell "kept the last N" apart from e.g. "kept the FIRST N" or any +# other N lines. We assert the exact retained `issue` values, in order, +# mirroring log-event.test.sh's `want` array. +# --------------------------------------------------------------------------- +ticks13="$work/scenario13-ticks.jsonl" +for i in 1 2 3 4 5 6 7; do + dir13="$(new_fixture "scenario13-$i" "open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=$i branch=none title=Rotation issue $i +advance_ready=$i +cadence=FAST cron=* * * * *" '')" + LOOP_TICKS_MAX_LINES=3 CLAUDE_TICKS_FILE="$ticks13" run_tick "$dir13" >/dev/null +done +check "scenario 13: rotation caps the tick log to exactly 3 lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 3 ]' _ "$ticks13" +check "scenario 13: every remaining line is still valid JSON after rotation" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + for (const l of lines) JSON.parse(l); +' "$ticks13" +check "scenario 13: rotation keeps the LAST 3 ticks (issues 5,6,7), in order" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + const issues = lines.map((l) => JSON.parse(l).issue); + const want = ["5", "6", "7"]; + if (JSON.stringify(issues) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(issues) + " want " + JSON.stringify(want)); + } +' "$ticks13" + +# Rotation boundary: writing EXACTLY LOOP_TICKS_MAX_LINES ticks must leave +# exactly that many lines -- i.e. rotation must not trigger (or drop +# anything) right at the boundary, only once the count exceeds the cap. +ticks13b="$work/scenario13b-ticks.jsonl" +for i in 1 2 3; do + dir13b="$(new_fixture "scenario13b-$i" "open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=$i branch=none title=Boundary issue $i +advance_ready=$i +cadence=FAST cron=* * * * *" '')" + LOOP_TICKS_MAX_LINES=3 CLAUDE_TICKS_FILE="$ticks13b" run_tick "$dir13b" >/dev/null +done +check "scenario 13b: writing exactly LOOP_TICKS_MAX_LINES ticks leaves exactly that many lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 3 ]' _ "$ticks13b" +check "scenario 13b: boundary case keeps all 3 ticks in order (no spurious drop)" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + const issues = lines.map((l) => JSON.parse(l).issue); + const want = ["1", "2", "3"]; + if (JSON.stringify(issues) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(issues) + " want " + JSON.stringify(want)); + } +' "$ticks13b" + +# --------------------------------------------------------------------------- +# 12. Best-effort: tick recording must never disturb the verdict or exit +# status, even when the ticks file cannot be written at all (its parent +# dir path collides with a plain file, so mkdir -p fails). +# --------------------------------------------------------------------------- +dir14="$(new_fixture scenario14 'open_prs=0 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=IDLE cron=*/15 * * * *' '')" +blocker="$work/scenario14-blocker" +: > "$blocker" # a plain FILE where the ticks file's PARENT DIR needs to be +out14="$(CLAUDE_TICKS_FILE="$blocker/loop-ticks.jsonl" run_tick "$dir14" 2>/dev/null)" +rc14=$? +check "scenario 14: tick-record write failure never changes the exit status" [ "$rc14" -eq 0 ] +check "scenario 14: verdict is still the LAST stdout line despite the write failure" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=none" ]' _ "$out14" + echo "" if [ "$fail" -eq 0 ]; then echo "loop-tick.test.sh: PASS ($ok checks)"