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
loop not armed
Last tick: ${esc(last.ts)} · verdict ${esc(last.verdict)}
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 += `| Time | Verdict | Cadence |
|---|---|---|
| ${esc(t.ts)} | ${esc(t.verdict)} | ${esc(t.cadence)} |
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(/([^<]*)<\/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)"