From 61456e7c1d40f603a15cf27483b6d7fa5c89679f Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:20:50 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(loop):=20add=20spend=20ceilings=20?= =?UTF-8?q?=E2=80=94=20stop-after,=20per-issue=20attempt=20budget,=20daily?= =?UTF-8?q?=20ceiling=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounds the autonomous PR loop's aggregate spend, mirroring gh-aw's cost-management design: an armed loop now self-disarms after budget.stop_after_days (default 7, recorded by arm-loop.sh in .claude/state/loop-arming.json, with a lazy fallback init for loops armed before this feature existed); an issue ping-ponging through budget.per_issue_attempts (default 5) advance/feedback dispatches without landing gets refused and labeled needs-human instead of retried forever; and a budget.daily_action_ceiling (default 50) halts new dispatches for the rest of the UTC day, filing/refreshing a single tracking issue, then resumes automatically at midnight. All three are pre-flight checks in loop-tick.sh's STEP 0, before the verdict decision, so a breach skips the spawn-lock side effect entirely; every gh side effect (notify/label/comment/file-issue) is once-guarded and best-effort. The cockpit's Loop health panel now surfaces stop-after countdown, today's action count vs the daily ceiling, and per-issue attempt counts vs the budget. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/arm-loop.sh | 57 ++++- .claude/scripts/cockpit.sh | 87 +++++++ .claude/scripts/cockpit.test.sh | 59 +++++ .claude/scripts/loop-ceilings.test.sh | 321 ++++++++++++++++++++++++++ .claude/scripts/loop-tick.sh | 317 ++++++++++++++++++++++++- .claude/self/gates.json | 7 +- docs/TOKEN_BUDGET.md | 36 ++- 7 files changed, 872 insertions(+), 12 deletions(-) create mode 100644 .claude/scripts/loop-ceilings.test.sh diff --git a/.claude/scripts/arm-loop.sh b/.claude/scripts/arm-loop.sh index 72a0ca4..b05a2b9 100755 --- a/.claude/scripts/arm-loop.sh +++ b/.claude/scripts/arm-loop.sh @@ -13,7 +13,7 @@ # then recreate). # # Usage: -# bash .claude/scripts/arm-loop.sh [--gates-file ] [--permission-mode ] [--capacity N] [--rc-name ] [--spawn ] +# bash .claude/scripts/arm-loop.sh [--gates-file ] [--permission-mode ] [--capacity N] [--rc-name ] [--spawn ] [--stop-after-days N] # # --gates-file passed to pr-loop.service as GATES_FILE (e.g. # .claude/self/gates.json for the self-hosted @@ -30,6 +30,16 @@ # --spawn remote-control spawn mode: same-dir (default) or # worktree. Passed explicitly so the server never # blocks on its interactive first-run question. +# --stop-after-days N self-disarm horizon (issue #95): loop-tick.sh +# refuses every advance/feedback dispatch once +# armed_at + N days has passed, until re-armed. +# Defaults to budget.stop_after_days in the +# adapter picked by --gates-file (or the default +# .claude/gates.json when --gates-file is +# omitted), else 7. Every re-arm rewrites +# .claude/state/loop-arming.json fresh -- +# clearing any prior expiry AND the one-time +# "disarmed" notification guard. set -euo pipefail gates_file="" @@ -37,6 +47,7 @@ permission_mode="" capacity="8" rc_name="" spawn_mode="same-dir" +stop_after_days="" while [ "$#" -gt 0 ]; do case "$1" in --gates-file) gates_file="${2:?--gates-file needs a value}"; shift 2 ;; @@ -49,8 +60,10 @@ while [ "$#" -gt 0 ]; do --spawn) spawn_mode="${2:?--spawn needs a value}"; shift 2 ;; --spawn=*) spawn_mode="${1#--spawn=}"; shift ;; --capacity=*) capacity="${1#--capacity=}"; shift ;; + --stop-after-days) stop_after_days="${2:?--stop-after-days needs a value}"; shift 2 ;; + --stop-after-days=*) stop_after_days="${1#--stop-after-days=}"; shift ;; -h|--help) - sed -n '2,32p' "$0" + sed -n '2,42p' "$0" exit 0 ;; *) echo "arm-loop.sh: unknown argument '$1'" >&2; exit 2 ;; @@ -96,6 +109,46 @@ if [ -n "$gates_file" ]; then gates_env="Environment=GATES_FILE=$gates_file" fi +# --- spend-ceiling arming state (issue #95) --------------------------------- +# Resolve the stop-after horizon: --stop-after-days wins; else +# budget.stop_after_days from the SAME adapter the armed daemon will read +# (gates_file, defaulting to .claude/gates.json); else 7. Always WRITE a +# fresh .claude/state/loop-arming.json on every arm/re-arm -- this is what +# clears a prior expiry and the one-time "disarmed" notification guard. +if [ -z "$stop_after_days" ]; then + adapter_for_stop_after="${gates_file:-.claude/gates.json}" + case "$adapter_for_stop_after" in + /*) ;; + *) adapter_for_stop_after="$repo_root/$adapter_for_stop_after" ;; + esac + stop_after_days="$(node -e ' + try { + const g = require(process.argv[1]); + const d = g && g.budget && g.budget.stop_after_days; + if (Number.isFinite(d) && d > 0) { console.log(d); process.exit(0); } + } catch (e) {} + ' "$adapter_for_stop_after" 2>/dev/null || true)" + stop_after_days="${stop_after_days:-7}" +fi +case "$stop_after_days" in + ''|*[!0-9.]*) echo "arm-loop.sh: --stop-after-days must be a positive number (got '$stop_after_days')" >&2; exit 2 ;; +esac + +arming_state_dir="$repo_root/.claude/state" +mkdir -p "$arming_state_dir" +arm_now="$(date -u +%FT%TZ)" +node -e ' + const fs = require("fs"); + const now = process.argv[2]; + const days = parseFloat(process.argv[3]); + const expires = new Date(Date.parse(now) + days * 86400000).toISOString(); + fs.writeFileSync(process.argv[1], JSON.stringify({ + armed_at: now, expires_at: expires, stop_after_days: days, + notified_expired: false, notice_issue: null, + }, null, 2) + "\n"); +' "$arming_state_dir/loop-arming.json" "$arm_now" "$stop_after_days" +echo "arm-loop.sh: armed until $(node -e 'const j=require(process.argv[1]);console.log(j.expires_at)' "$arming_state_dir/loop-arming.json") (stop_after_days=$stop_after_days) -- .claude/state/loop-arming.json" + # Absolute claude path, resolved HERE — this script runs in a real terminal # with the user's full environment, while the installed unit runs under # systemd's minimal PATH (gh but no nvm-provisioned node/claude). A bare diff --git a/.claude/scripts/cockpit.sh b/.claude/scripts/cockpit.sh index b246f35..550a721 100755 --- a/.claude/scripts/cockpit.sh +++ b/.claude/scripts/cockpit.sh @@ -233,6 +233,26 @@ else fi if [ -f "$ticks_file" ]; then cp "$ticks_file" "$tmpdir/loop-ticks.jsonl"; else : >"$tmpdir/loop-ticks.jsonl"; fi +# ---- spend-ceiling state (issue #95, "Loop health" panel additions) -------- +# Same offline seam as the events/ticks blocks above: fixtures mode reads +# /loop-arming.json, /loop-issue-attempts.json, +# /loop-daily-ceiling.json (if present); otherwise the real, gitignored +# .claude/state/ files loop-tick.sh itself reads/writes. A missing file just +# means that ceiling has never tripped/been armed yet -- rendered as a +# placeholder below, never an error. +if [ -n "$fixtures" ]; then + arming_file="$fixtures/loop-arming.json" + attempts_file="$fixtures/loop-issue-attempts.json" + daily_ceiling_file="$fixtures/loop-daily-ceiling.json" +else + arming_file="$root/.claude/state/loop-arming.json" + attempts_file="$root/.claude/state/loop-issue-attempts.json" + daily_ceiling_file="$root/.claude/state/loop-daily-ceiling.json" +fi +if [ -f "$arming_file" ]; then cp "$arming_file" "$tmpdir/loop-arming.json"; else echo "{}" >"$tmpdir/loop-arming.json"; fi +if [ -f "$attempts_file" ]; then cp "$attempts_file" "$tmpdir/loop-issue-attempts.json"; else echo "{}" >"$tmpdir/loop-issue-attempts.json"; fi +if [ -f "$daily_ceiling_file" ]; then cp "$daily_ceiling_file" "$tmpdir/loop-daily-ceiling.json"; else echo "{}" >"$tmpdir/loop-daily-ceiling.json"; fi + # ---- active worktrees ----------------------------------------------------------- node -e ' const fs = require("fs"); @@ -314,6 +334,13 @@ function readTicks() { } const ticks = readTicks(); +// Spend-ceiling state (issue #95): three small JSON objects gathered by the +// bash prelude above. Each defaults to {} (never null/undefined) so the +// render code below can dot into them without a guard on every access. +const arming = readJson("loop-arming.json", {}); +const issueAttempts = readJson("loop-issue-attempts.json", {}); +const dailyCeiling = readJson("loop-daily-ceiling.json", {}); + function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") @@ -553,10 +580,70 @@ function renderLoopHealth() { } html += ``; + html += renderSpendCeilings(); + html += ``; return html; } +// ---- Spend ceilings sub-section (issue #95) -------------------------------- +// Best-effort surfacing of the three loop spend ceilings inside the SAME +// "Loop health" section: stop-after expiry/countdown, today's dispatch count +// vs the daily ceiling, and per-issue attempt counts vs the per-issue budget. +// Reads adapter.budget for the configured thresholds (falling back to the +// same defaults loop-tick.sh itself uses when a key is absent), so this +// panel and the enforcement it describes never drift out of sync. +function renderSpendCeilings() { + const b = adapter.budget || {}; + const perIssueAttempts = Number.isFinite(b.per_issue_attempts) ? b.per_issue_attempts : 5; + const dailyCeilingCfg = Number.isFinite(b.daily_action_ceiling) ? b.daily_action_ceiling : 50; + + let html = `

Spend ceilings

`; + + // --- stop-after expiry/countdown --- + if (arming && arming.expires_at) { + const expMs = Date.parse(arming.expires_at); + let line = `Stop-after: ${esc(arming.expires_at)}`; + if (Number.isFinite(expMs)) { + const diffMs = expMs - nowMs; + if (diffMs > 0) { + const days = Math.floor(diffMs / 86400000); + const hours = Math.floor((diffMs % 86400000) / 3600000); + line += ` (in ${days}d ${hours}h)`; + } else { + line += ` DISARMED — re-arm to resume`; + } + } + html += `

${line}

`; + } else { + html += `

Stop-after: not armed yet

`; + } + + // --- today's dispatch count vs the daily ceiling --- + const today = new Date(nowMs).toISOString().slice(0, 10); + const todaysCount = dailyCeiling && dailyCeiling.date === today ? (dailyCeiling.count || 0) : 0; + const overDaily = todaysCount >= dailyCeilingCfg; + html += `

Today's actions: ${todaysCount} / ${dailyCeilingCfg}

`; + + // --- per-issue attempt counts vs the per-issue budget --- + const attemptEntries = Object.entries(issueAttempts || {}).filter(([, v]) => v && (v.attempts || 0) > 0); + if (attemptEntries.length === 0) { + html += `

Per-issue attempts: none tracked yet

`; + } else { + attemptEntries.sort((a, b2) => (b2[1].attempts || 0) - (a[1].attempts || 0)); + html += ``; + for (const [k, v] of attemptEntries) { + const n = parseInt(k, 10); + const over = (v.attempts || 0) >= perIssueAttempts; + const status = over ? `needs-human` : ""; + html += ``; + } + html += `
IssueAttemptsStatus
${Number.isFinite(n) ? refLink(n) : esc(k)}${esc(v.attempts)}/${perIssueAttempts}${status}
`; + } + + return html; +} + // ---- Issues section: group by module label, parse blocking graph per issue ---- function renderIssues() { if (issuesUnavailable) { diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh index b8d9c87..47cf424 100755 --- a/.claude/scripts/cockpit.test.sh +++ b/.claude/scripts/cockpit.test.sh @@ -316,6 +316,65 @@ check "verdict-history default cap (10) does not truncate a shorter (5-tick) his } ' "$html_history_default" +# --------------------------------------------------------------------------- +# 2d. Spend ceilings sub-panel (issue #95): stop-after countdown, today's +# action count vs the daily ceiling, and per-issue attempts vs the +# per-issue budget, sourced from loop-arming.json/loop-issue-attempts.json/ +# loop-daily-ceiling.json. Missing files (exercised separately in section +# 4 below) must degrade to placeholders, never a crash. +# --------------------------------------------------------------------------- +mkdir -p "$work/fixtures-ceilings" +cat > "$work/fixtures-ceilings/issues.json" <<'EOF' +[{"number":100,"title":"Known issue","url":"https://example.com/100","labels":[],"body":""}] +EOF +echo "[]" >"$work/fixtures-ceilings/prs.json" +: >"$work/fixtures-ceilings/events.jsonl" +cat > "$work/fixtures-ceilings/loop-ticks.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","verdict":"action=none","cadence":"IDLE","action":"none","issue":"","pr":"","reason":"daily-ceiling"} +EOF +cat > "$work/fixtures-ceilings/loop-arming.json" <<'EOF' +{"armed_at":"2026-01-01T00:00:00Z","expires_at":"2026-01-05T00:00:00Z","stop_after_days":4,"notified_expired":false,"notice_issue":null} +EOF +cat > "$work/fixtures-ceilings/loop-issue-attempts.json" <<'EOF' +{"100":{"attempts":3,"escalated":false},"42":{"attempts":5,"escalated":true}} +EOF +cat > "$work/fixtures-ceilings/loop-daily-ceiling.json" <<'EOF' +{"date":"2026-01-01","count":37,"halted":false,"issue_number":null} +EOF +html_ceilings="$work/cockpit-ceilings.html" +COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings" "$html_ceilings" >/dev/null 2>"$work/stderr-ceilings.log" +check "spend ceilings sub-heading present" grep -qF '

Spend ceilings

' "$html_ceilings" +check "stop-after expiry + countdown rendered" grep -qF '2026-01-05T00:00:00Z (in 3d 23h)' "$html_ceilings" +check "today's action count vs the daily ceiling (adapter default 50) rendered" grep -qF '37 / 50' "$html_ceilings" +check "per-issue attempts table: over-budget issue 42 shows needs-human status" grep -qF '#425/5needs-human' "$html_ceilings" +check "per-issue attempts table: known issue 100 links to its section anchor" grep -qF '#1003/5' "$html_ceilings" + +# Expired stop-after (past expires_at) renders the DISARMED badge instead of a countdown. +cat > "$work/fixtures-ceilings/loop-arming.json" <<'EOF' +{"armed_at":"2025-12-01T00:00:00Z","expires_at":"2025-12-08T00:00:00Z","stop_after_days":7,"notified_expired":true,"notice_issue":123} +EOF +html_ceilings_expired="$work/cockpit-ceilings-expired.html" +COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings" "$html_ceilings_expired" >/dev/null 2>"$work/stderr-ceilings-expired.log" +check "expired stop-after renders the DISARMED badge, not a countdown" grep -qF 'DISARMED' "$html_ceilings_expired" + +# The loop HAS ticked (so the section renders past the early "loop not armed" +# return) but none of the three spend-ceiling files exist yet -- read-only +# cockpit.sh never lazily creates them the way loop-tick.sh itself does; it +# must just degrade to placeholders, never crash. +mkdir -p "$work/fixtures-ceilings-missing" +cat > "$work/fixtures-ceilings-missing/issues.json" <<'EOF' +[] +EOF +echo "[]" >"$work/fixtures-ceilings-missing/prs.json" +: >"$work/fixtures-ceilings-missing/events.jsonl" +cp "$work/fixtures-ceilings/loop-ticks.jsonl" "$work/fixtures-ceilings-missing/loop-ticks.jsonl" +html_ceilings_missing="$work/cockpit-ceilings-missing.html" +COCKPIT_NOW="2026-01-01T00:10:00Z" bash "$cockpit" --fixtures "$work/fixtures-ceilings-missing" "$html_ceilings_missing" >/dev/null 2>"$work/stderr-ceilings-missing.log" +check "no spend-ceiling state files: generator still exits 0 (no crash)" [ -s "$html_ceilings_missing" ] +check "no spend-ceiling state files: stop-after degrades to 'not armed yet'" grep -qF 'Stop-after: not armed yet' "$html_ceilings_missing" +check "no spend-ceiling state files: per-issue attempts degrades to 'none tracked yet'" grep -qF 'Per-issue attempts: none tracked yet' "$html_ceilings_missing" +check "no spend-ceiling state files: today's actions default to 0 / adapter ceiling" grep -qF '0 / 50' "$html_ceilings_missing" + # --------------------------------------------------------------------------- # 3. GATES_FILE override is honored (self-host adapter), still with fixtures # (no gh/network either way). diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh new file mode 100644 index 0000000..ea5c0dd --- /dev/null +++ b/.claude/scripts/loop-ceilings.test.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# loop-ceilings.test.sh — offline smoke test for loop-tick.sh's spend-ceiling +# pre-flight (issue #95): stop-after self-disarm, per-issue advance/feedback +# attempt budget, and the daily action ceiling. +# +# Same fixture strategy as loop-tick.test.sh: a throwaway /.claude/ +# tree containing the REAL loop-tick.sh + resolve-roots.sh next to FAKE +# loop-census.sh/notify-poll.sh/merge-ready.sh/pr-feedback.sh that print +# canned output, PLUS a fake bot-gh.sh (loop-tick.sh's own `gh` wrapper calls +# it via bot-gh.sh under $script_dir) that logs every invocation to a file +# instead of touching the network — so ceiling-breach scenarios (which DO +# call gh for the one-time notify/label/comment) still need zero network, +# and non-breach scenarios can assert bot-gh.sh was never even created/called. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/loop-ceilings.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +loop_tick_src="$script_dir/loop-tick.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/loop-ceilings-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# $1=name $2=fake_census $3=fake_feedback (TSV body, may be empty) +# $4=1 to also install a call-logging fake bot-gh.sh (default: no bot-gh.sh at +# all, matching loop-tick.test.sh's own "gh must never be invoked" contract +# for scenarios that expect zero gh side effects). +new_fixture() { + local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" + local dir="$work/$name/.claude/scripts" + mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null + rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself + cp "$loop_tick_src" "$dir/loop-tick.sh" + cp "$resolve_roots_src" "$dir/resolve-roots.sh" + + cat > "$dir/loop-census.sh" < "$dir/notify-poll.sh" <<'EOF' +#!/usr/bin/env bash +echo "CURSOR=fake NOW=fake" +EOF + cat > "$dir/merge-ready.sh" <<'EOF' +#!/usr/bin/env bash +echo "=== merge-ready: merged=0 skipped=0 ===" +EOF + cat > "$dir/pr-feedback.sh" < "$dir/bot-gh.sh" <<'EOF' +#!/usr/bin/env bash +log="$(dirname "$0")/../state/gh-calls.log" +mkdir -p "$(dirname "$log")" +printf '%s\n' "$*" >> "$log" +case "$1 $2" in + "issue create") echo "https://github.com/acme/repo/issues/777" ;; + "issue view") echo "${FAKE_ISSUE_STATE:-OPEN}" ;; +esac +exit 0 +EOF + chmod +x "$dir/bot-gh.sh" + fi + + printf '%s\n' "$dir" +} + +run_tick() { + # $1 = fixture script_dir; repo passed explicitly so loop-tick.sh's own gh() + # (bot-gh.sh) never hits real network even when a fake bot-gh.sh IS present. + bash "$1/loop-tick.sh" "acme/repo" +} + +gh_calls() { + # $1 = fixture script_dir. Prints the fake bot-gh.sh call log (empty/missing + # is fine -- `cat` on a missing file just prints nothing under `|| true`). + cat "$1/../state/gh-calls.log" 2>/dev/null || true +} + +verdict_of() { printf '%s\n' "$1" | tail -1; } +# Exported so the `check ... bash -c '...' _ "$arg"` pattern below (a FRESH +# bash subprocess, which does not inherit un-exported shell functions) can +# still call these two helpers. +export -f gh_calls verdict_of + +CENSUS_READY_42='open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=42 branch=none title=Do the thing +advance_ready=42 +cadence=FAST cron=* * * * *' + +FEEDBACK_PR_17='17 feat/issue-42-x owner 2026-01-01T00:00:00Z' + +# --------------------------------------------------------------------------- +# 1. Stop-after: expiry in the FUTURE -> advance proceeds normally, no gh +# calls, and the tick record's `reason` field is empty (no ceiling fired). +# --------------------------------------------------------------------------- +dir1="$(new_fixture scenario1 "$CENSUS_READY_42" "" 0)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-arming.json", JSON.stringify({ + armed_at: "2026-01-01T00:00:00Z", expires_at: "2099-01-01T00:00:00Z", + stop_after_days: 7, notified_expired: false, notice_issue: null, + })); +' "$dir1" +ticks1="$work/scenario1-ticks.jsonl" +out1="$(CLAUDE_TICKS_FILE="$ticks1" run_tick "$dir1")" +check "scenario 1 (future expiry): advance proceeds" bash -c '[ "$(printf "%s\n" "$1" | tail -1)" = "action=advance issue=42" ]' _ "$out1" +check "scenario 1: no gh side effects (no bot-gh.sh exists, and none is needed)" [ ! -f "$dir1/bot-gh.sh" ] +check "scenario 1: tick record reason is empty (no ceiling fired)" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.reason !== "") throw new Error("expected empty reason, got " + JSON.stringify(obj)); +' "$ticks1" + +# --------------------------------------------------------------------------- +# 2. Stop-after: expiry in the PAST -> action=none reason=expired, ONE-TIME +# notify (gh issue create), and the notify guard prevents a second call on +# the very next tick even though the loop stays expired. +# --------------------------------------------------------------------------- +dir2="$(new_fixture scenario2 "$CENSUS_READY_42" "" 1)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-arming.json", JSON.stringify({ + armed_at: "2020-01-01T00:00:00Z", expires_at: "2020-01-08T00:00:00Z", + stop_after_days: 7, notified_expired: false, notice_issue: null, + })); +' "$dir2" +out2="$(run_tick "$dir2")" +check "scenario 2 (past expiry): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out2" +check "scenario 2: diagnostic cites the expiry" bash -c 'printf "%s\n" "$1" | grep -q "loop disarmed (stop-after expired"' _ "$out2" +check "scenario 2: exactly one gh call was made (the one-time notify)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir2" +check "scenario 2: the notify call was 'issue create'" bash -c 'gh_calls "$1" | grep -q "^issue create"' _ "$dir2" +check "scenario 2: notified_expired is now persisted true" bash -c ' + node -e "const j=require(process.argv[1]); if(j.notified_expired!==true) process.exit(1); if(j.notice_issue!==777) process.exit(1);" "$1/../state/loop-arming.json" +' _ "$dir2" +# Second tick: still expired, but the guard must suppress a second notify. +out2b="$(run_tick "$dir2")" +check "scenario 2b (still expired, second tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out2b" +check "scenario 2b: no ADDITIONAL gh call (guard held) -- still exactly 1 total" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir2" + +# --------------------------------------------------------------------------- +# 3. Stop-after: no arming file at all (pre-#95 armed loop) -> loop-tick.sh +# lazily self-inits one starting NOW, so the FIRST tick still proceeds +# (not expired) and a ceiling now exists going forward. +# --------------------------------------------------------------------------- +dir3="$(new_fixture scenario3 "$CENSUS_READY_42" "" 0)" +out3="$(run_tick "$dir3")" +check "scenario 3 (no arming file, fallback init): advance proceeds" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out3" +check "scenario 3: loop-arming.json now exists with a future expires_at" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!j.expires_at || Date.parse(j.expires_at) <= Date.now()) throw new Error("expected a future expires_at, got " + JSON.stringify(j)); +' "$dir3/../state/loop-arming.json" + +# --------------------------------------------------------------------------- +# 4. Per-issue attempt budget: UNDER budget (default 5) -> advance proceeds, +# and the attempt counter increments by exactly one for issue=42. +# --------------------------------------------------------------------------- +dir4="$(new_fixture scenario4 "$CENSUS_READY_42" "" 0)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-issue-attempts.json", JSON.stringify({ "42": { attempts: 2, escalated: false } })); +' "$dir4" +out4="$(run_tick "$dir4")" +check "scenario 4 (attempts 2 < budget 5): advance proceeds" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out4" +check "scenario 4: attempts incremented from 2 to 3 for issue 42" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j["42"].attempts !== 3) throw new Error("expected 3, got " + JSON.stringify(j)); +' "$dir4/../state/loop-issue-attempts.json" + +# --------------------------------------------------------------------------- +# 5. Per-issue attempt budget: AT budget -> refused (action=none, +# reason=attempt-budget), issue is labeled+commented needs-human EXACTLY +# ONCE (escalated guard), and a second tick makes no further gh calls. +# --------------------------------------------------------------------------- +dir5="$(new_fixture scenario5 "$CENSUS_READY_42" "" 1)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-issue-attempts.json", JSON.stringify({ "42": { attempts: 5, escalated: false } })); +' "$dir5" +out5="$(run_tick "$dir5")" +check "scenario 5 (attempts 5 >= budget 5): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5" +check "scenario 5: diagnostic cites the attempt budget" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out5" +check "scenario 5: exactly 3 gh calls (label create, issue edit, issue comment)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 3 ]' _ "$dir5" +check "scenario 5: the issue itself (not a PR) was labeled needs-human" bash -c 'gh_calls "$1" | grep -q "^issue edit 42 --add-label needs-human"' _ "$dir5" +check "scenario 5: escalated is now persisted true, attempts unchanged at 5" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j["42"].escalated !== true || j["42"].attempts !== 5) throw new Error("got " + JSON.stringify(j)); +' "$dir5/../state/loop-issue-attempts.json" +out5b="$(run_tick "$dir5")" +check "scenario 5b (still over budget, second tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5b" +check "scenario 5b: no additional gh calls (escalated guard held) -- still exactly 3" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 3 ]' _ "$dir5" + +# --------------------------------------------------------------------------- +# 6. Per-issue attempt budget applies across advance AND feedback phases of +# the SAME issue: a PR (17) cut from feat/issue-42-x inherits issue 42's +# existing attempt count and is refused/escalated as PR 17 (not issue 42). +# --------------------------------------------------------------------------- +dir6="$(new_fixture scenario6 "$CENSUS_READY_42" "$FEEDBACK_PR_17" 1)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-issue-attempts.json", JSON.stringify({ "42": { attempts: 5, escalated: false } })); +' "$dir6" +out6="$(run_tick "$dir6")" +check "scenario 6 (feedback for issue 42's PR, budget already exhausted): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out6" +check "scenario 6: the PR (17), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -q "^pr edit 17 --add-label needs-human" && gh_calls "$1" | grep -q "^pr comment 17"' _ "$dir6" + +# --------------------------------------------------------------------------- +# 7. Daily action ceiling: UNDER the ceiling (default 50) -> advance +# proceeds, count increments by exactly one for today. +# --------------------------------------------------------------------------- +dir7="$(new_fixture scenario7 "$CENSUS_READY_42" "" 0)" +today="$(date -u +%Y-%m-%d)" +CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: process.env.CLAUDE_TODAY, count: 10, halted: false, issue_number: null })); +' "$dir7" +out7="$(run_tick "$dir7")" +check "scenario 7 (count 10 < ceiling 50): advance proceeds" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out7" +check "scenario 7: daily count incremented from 10 to 11" env CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j.count !== 11 || j.date !== process.env.CLAUDE_TODAY) throw new Error("got " + JSON.stringify(j)); +' "$dir7/../state/loop-daily-ceiling.json" + +# --------------------------------------------------------------------------- +# 8. Daily action ceiling: AT the ceiling -> action=none reason=daily-ceiling, +# a SINGLE tracking issue is filed, and a second same-day tick makes no +# additional gh calls (halted guard) even though the breach persists. +# --------------------------------------------------------------------------- +dir8="$(new_fixture scenario8 "$CENSUS_READY_42" "" 1)" +CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: process.env.CLAUDE_TODAY, count: 50, halted: false, issue_number: null })); +' "$dir8" +out8="$(run_tick "$dir8")" +check "scenario 8 (count 50 >= ceiling 50): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out8" +check "scenario 8: diagnostic cites the daily ceiling" bash -c 'printf "%s\n" "$1" | grep -q "daily action ceiling reached"' _ "$out8" +check "scenario 8: exactly one gh call (the tracking-issue file)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir8" +check "scenario 8: halted persisted true with the filed issue number recorded" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j.halted !== true || j.issue_number !== 777) throw new Error("got " + JSON.stringify(j)); +' "$dir8/../state/loop-daily-ceiling.json" +out8b="$(run_tick "$dir8")" +check "scenario 8b (still halted, second same-day tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out8b" +check "scenario 8b: no additional gh call (halted guard held) -- still exactly 1" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir8" + +# --------------------------------------------------------------------------- +# 9. Daily action ceiling resets automatically on a calendar-date change, and +# REUSES (refreshes) the same tracking issue rather than filing a dupe. +# --------------------------------------------------------------------------- +dir9="$(new_fixture scenario9 "$CENSUS_READY_42" "" 1)" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: "2020-01-01", count: 999, halted: true, issue_number: 555 })); +' "$dir9" +out9="$(run_tick "$dir9")" +check "scenario 9 (stale date from a prior day): advance proceeds -- counter reset" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out9" +check "scenario 9: today's count is now 1 (fresh day), issue_number 555 preserved for future refresh" env CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j.count !== 1 || j.date !== process.env.CLAUDE_TODAY || j.issue_number !== 555) throw new Error("got " + JSON.stringify(j)); +' "$dir9/../state/loop-daily-ceiling.json" +check "scenario 9: no gh calls (a normal dispatch never itself calls gh)" bash -c '[ -z "$(gh_calls "$1")" ]' _ "$dir9" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "loop-ceilings.test.sh: PASS ($ok checks)" + exit 0 +else + echo "loop-ceilings.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh index 8aef437..6abeef7 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -108,7 +108,7 @@ repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" # LOOP_TICKS_MAX_LINES (default 2000), matching log-event.sh's # EVENTS_MAX_LINES. write_tick_record() { - local verdict="$1" cadence="$2" + local verdict="$1" cadence="$2" reason="${3:-}" local ticks_file="${CLAUDE_TICKS_FILE:-$root/.claude/state/loop-ticks.jsonl}" local max_lines="${LOOP_TICKS_MAX_LINES:-2000}" local action="" issue="" pr="" @@ -133,6 +133,7 @@ write_tick_record() { CLAUDE_TICK_ACTION="$action" \ CLAUDE_TICK_ISSUE="$issue" \ CLAUDE_TICK_PR="$pr" \ + CLAUDE_TICK_REASON="$reason" \ node -e ' const line = JSON.stringify({ ts: process.env.CLAUDE_TICK_TS || "", @@ -141,6 +142,10 @@ write_tick_record() { action: process.env.CLAUDE_TICK_ACTION || "", issue: process.env.CLAUDE_TICK_ISSUE || "", pr: process.env.CLAUDE_TICK_PR || "", + // Spend-ceiling diagnostic (issue #95): empty unless a ceiling forced + // the verdict to action=none -- one of + // expired|daily-ceiling|attempt-budget. Surfaced by cockpit.sh. + reason: process.env.CLAUDE_TICK_REASON || "", }); process.stdout.write(line + "\n"); ' >>"$ticks_file" 2>/dev/null || return 0 @@ -195,7 +200,14 @@ cadence="$(printf '%s\n' "$census_out" | sed -n 's/^cadence=\([A-Za-z]*\).*/\1/p # --- Parse pr-feedback.sh's TSV (num, branch, reviewer, changes_requested_at) -- # Lowest-numbered PR wins when several need feedback addressed. -feedback_pr="$(printf '%s\n' "$feedback_out" | awk -F'\t' 'NF>=1 && $1 ~ /^[0-9]+$/ {print $1}' | sort -n | head -1)" +feedback_line="$(printf '%s\n' "$feedback_out" | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1"\t"$2}' | sort -t $'\t' -k1,1n | head -1)" +feedback_pr="$(printf '%s\n' "$feedback_line" | awk -F'\t' '{print $1}')" +feedback_branch="$(printf '%s\n' "$feedback_line" | awk -F'\t' '{print $2}')" +# The issue this PR's branch was cut from (feat/issue-N-*), used to key the +# per-issue attempt budget (issue #95) so advance-phase and feedback-phase +# dispatches for the SAME issue share one counter. Falls back to the PR +# number itself when the branch doesn't follow that convention. +feedback_issue="$(printf '%s\n' "$feedback_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" # --- Spawn lock: read + self-heal against the FRESH census above ----------- # TTL rationale: this lock is written the instant a tick emits @@ -260,12 +272,254 @@ if [ -n "$lock_issue" ]; then fi fi +# --------------------------------------------------------------------------- +# STEP 0: spend-ceiling pre-flight (issue #95). Three independent, adapter- +# configurable ceilings (defaults documented in docs/TOKEN_BUDGET.md -> +# "Loop spend ceilings"): +# budget.stop_after_days self-disarm horizon (armed_at + Nd) +# budget.per_issue_attempts advance/feedback dispatch budget PER ISSUE +# budget.daily_action_ceiling dispatches (advance+feedback) per UTC day +# A breach sets ceiling_block (non-empty), which forces the verdict decided +# below to action=none WITHOUT the spawn-lock side effect, and ceiling_reason +# (persisted on the tick record for the cockpit): one of +# expired|daily-ceiling|attempt-budget. Every gh side effect below (notify / +# label / comment) is best-effort and ONCE-guarded via small state files +# under .claude/state/ -- a failure here can never break a tick, and normal +# ticks (no breach) never call gh at all. +# --------------------------------------------------------------------------- +gates_rel="${GATES_FILE:-.claude/gates.json}" +case "$gates_rel" in /*) gates_path="$gates_rel" ;; *) gates_path="$root/$gates_rel" ;; esac +budget_cfg="$(node -e ' + const fs = require("fs"); + let g = null; + try { g = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { g = null; } + const b = (g && g.budget) || {}; + const num = (v, d) => (Number.isFinite(v) && v > 0 ? v : d); + console.log([num(b.stop_after_days, 7), num(b.per_issue_attempts, 5), num(b.daily_action_ceiling, 50)].join(" ")); +' "$gates_path" 2>/dev/null)" +stop_after_days="$(printf '%s\n' "$budget_cfg" | awk '{print $1}')" +per_issue_attempts="$(printf '%s\n' "$budget_cfg" | awk '{print $2}')" +daily_action_ceiling="$(printf '%s\n' "$budget_cfg" | awk '{print $3}')" +case "$stop_after_days" in ''|*[!0-9.]*) stop_after_days=7 ;; esac +case "$per_issue_attempts" in ''|*[!0-9]*) per_issue_attempts=5 ;; esac +case "$daily_action_ceiling" in ''|*[!0-9]*) daily_action_ceiling=50 ;; esac + +# File-or-refresh-ONE-issue helper, shared by the expiry and daily-ceiling +# notices below. $1 = existing tracked issue number (may be empty), $2 = +# title (used only when filing new), $3 = body. Prints the issue number that +# now tracks this notice (existing/refreshed, or freshly filed) -- empty on +# total gh failure (offline), which callers treat as "nothing to persist". +budget_notify_issue() { + local existing="$1" title="$2" body="$3" + if [ -n "$existing" ]; then + local st + st="$(gh issue view "$existing" --json state --jq .state 2>/dev/null || true)" + if [ "$st" = "OPEN" ]; then + gh issue comment "$existing" --body "$body" >/dev/null 2>&1 || true + printf '%s' "$existing" + return 0 + fi + fi + local out num + out="$(gh issue create --title "$title" --label backlog --body "$body" 2>/dev/null || true)" + num="$(printf '%s\n' "$out" | grep -oE '[0-9]+$' | tail -1)" + printf '%s' "$num" +} + +ceiling_block="" +ceiling_reason="" + +# --- 1) stop-after self-disarm ---------------------------------------------- +# .claude/state/loop-arming.json is normally written by arm-loop.sh at arm +# time (armed_at/expires_at/stop_after_days/notified_expired/notice_issue). +# Fallback: an already-armed loop from before this feature existed never had +# arm-loop.sh write one -- lazily create one starting NOW on first tick, so +# it still gets a ceiling instead of running forever unnoticed. +arming_file="$state_dir/loop-arming.json" +now_iso="$(date -u +%FT%TZ)" +if [ ! -f "$arming_file" ]; then + tmp_arm="$(mktemp "$state_dir/.loop-arming.json.XXXXXX")" + if CLAUDE_ARM_NOW="$now_iso" CLAUDE_ARM_DAYS="$stop_after_days" node -e ' + const fs = require("fs"); + const now = process.env.CLAUDE_ARM_NOW; + const days = parseFloat(process.env.CLAUDE_ARM_DAYS) || 7; + const expires = new Date(Date.parse(now) + days * 86400000).toISOString(); + fs.writeFileSync(process.argv[1], JSON.stringify({ + armed_at: now, expires_at: expires, stop_after_days: days, + notified_expired: false, notice_issue: null, + }, null, 2) + "\n"); + ' "$tmp_arm" 2>/dev/null; then + mv -f "$tmp_arm" "$arming_file" + else + rm -f "$tmp_arm" + fi +fi + +expired=0 +expires_at="" notified_expired="0" arming_notice_issue="" +if [ -f "$arming_file" ]; then + arm_read="$(node -e ' + const fs = require("fs"); + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + console.log((j.expires_at||"") + "\t" + (j.notified_expired?1:0) + "\t" + (j.notice_issue||"")); + } catch (e) { console.log("\t0\t"); } + ' "$arming_file" 2>/dev/null || printf '\t0\t')" + IFS=$'\t' read -r expires_at notified_expired arming_notice_issue <<<"$arm_read" + case "$notified_expired" in ''|*[!01]*) notified_expired=0 ;; esac + if [ -n "$expires_at" ]; then + now_epoch="$(date -u +%s)" + expires_epoch="$(date -u -d "$expires_at" +%s 2>/dev/null || echo 0)" + if [ "$expires_epoch" -gt 0 ] && [ "$now_epoch" -gt "$expires_epoch" ]; then + expired=1 + fi + fi +fi + +if [ "$expired" -eq 1 ]; then + ceiling_block="1"; ceiling_reason="expired" + echo "# pre-flight: loop disarmed (stop-after expired at $expires_at)" + if [ "$notified_expired" != "1" ]; then + body="The armed loop's stop-after horizon (armed_at + ${stop_after_days}d) expired at $expires_at. It stays disarmed -- no further advance/feedback dispatches -- until re-armed. Re-arm with \`/pr-loop\` or \`bash .claude/scripts/arm-loop.sh\`." + new_issue="$(budget_notify_issue "$arming_notice_issue" "Loop disarmed: stop-after expired" "$body")" + tmp_arm="$(mktemp "$state_dir/.loop-arming.json.XXXXXX")" + if CLAUDE_NEW_ISSUE="${new_issue:-}" node -e ' + const fs = require("fs"); + let j = {}; + try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) {} + j.notified_expired = true; + const ni = process.env.CLAUDE_NEW_ISSUE; + if (ni) j.notice_issue = parseInt(ni, 10); + fs.writeFileSync(process.argv[2], JSON.stringify(j, null, 2) + "\n"); + ' "$arming_file" "$tmp_arm" 2>/dev/null; then + mv -f "$tmp_arm" "$arming_file" + else + rm -f "$tmp_arm" + fi + fi +fi + +# --- 2) daily action ceiling ------------------------------------------------- +# .claude/state/loop-daily-ceiling.json: {date,count,halted,issue_number}. +# count/halted reset automatically once `date` no longer matches today; +# issue_number persists ACROSS the reset so a re-breach on a later day +# refreshes the same tracking issue instead of filing a duplicate. +daily_file="$state_dir/loop-daily-ceiling.json" +today="$(date -u +%Y-%m-%d)" +daily_read="$(node -e ' + const fs = require("fs"); + let j = {}; + try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) {} + const today = process.argv[2]; + const count = j.date === today ? (j.count||0) : 0; + const halted = j.date === today ? !!j.halted : false; + console.log(count + "\t" + (halted?1:0) + "\t" + (j.issue_number||"")); +' "$daily_file" "$today" 2>/dev/null || printf '0\t0\t')" +IFS=$'\t' read -r daily_count daily_halted daily_issue_num <<<"$daily_read" +case "$daily_count" in ''|*[!0-9]*) daily_count=0 ;; esac +case "$daily_halted" in ''|*[!01]*) daily_halted=0 ;; esac + +if [ -z "$ceiling_block" ] && [ "$daily_count" -ge "$daily_action_ceiling" ]; then + ceiling_block="1"; ceiling_reason="daily-ceiling" + echo "# pre-flight: daily action ceiling reached ($daily_count >= $daily_action_ceiling actions on $today)" + if [ "$daily_halted" != "1" ]; then + body="The autonomous loop hit its daily action ceiling (budget.daily_action_ceiling=$daily_action_ceiling) after $daily_count dispatched actions on $today (UTC). It halts for the rest of today and resumes automatically at UTC midnight. Raise budget.daily_action_ceiling in the adapter if this volume is expected." + new_issue="$(budget_notify_issue "$daily_issue_num" "Loop budget exceeded: daily action ceiling" "$body")" + [ -n "$new_issue" ] && daily_issue_num="$new_issue" + tmp_daily="$(mktemp "$state_dir/.loop-daily-ceiling.json.XXXXXX")" + if CLAUDE_TODAY="$today" CLAUDE_COUNT="$daily_count" CLAUDE_ISSUE="${daily_issue_num:-}" node -e ' + const fs = require("fs"); + const issue = process.env.CLAUDE_ISSUE ? parseInt(process.env.CLAUDE_ISSUE, 10) : null; + fs.writeFileSync(process.argv[1], JSON.stringify({ + date: process.env.CLAUDE_TODAY, + count: parseInt(process.env.CLAUDE_COUNT, 10) || 0, + halted: true, + issue_number: issue, + }, null, 2) + "\n"); + ' "$tmp_daily" 2>/dev/null; then + mv -f "$tmp_daily" "$daily_file" + else + rm -f "$tmp_daily" + fi + fi +fi + +# --- 3) per-issue advance/feedback attempt budget ---------------------------- +# .claude/state/loop-issue-attempts.json: { "": {attempts,escalated} }. +# Keyed by the ORIGINATING issue number (advance_ready directly; feedback via +# feedback_issue, parsed from the PR's feat/issue-N-* branch) so advance-phase +# and feedback-phase dispatches for the same issue share one counter -- the +# candidate mirrors the SAME preconditions the verdict decision below applies +# (feedback beats advance; in_flight/lock-held candidates are never charged). +attempts_file="$state_dir/loop-issue-attempts.json" +attempt_issue="" attempt_escalate_kind="" attempt_escalate_num="" +if [ -n "$feedback_pr" ]; then + attempt_issue="${feedback_issue:-$feedback_pr}" + attempt_escalate_kind="pr" + attempt_escalate_num="$feedback_pr" +elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ] \ + && ! printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready" \ + && [ "$lock_issue" != "$advance_ready" ]; then + attempt_issue="$advance_ready" + attempt_escalate_kind="issue" + attempt_escalate_num="$advance_ready" +fi + +if [ -z "$ceiling_block" ] && [ -n "$attempt_issue" ]; then + attempt_read="$(CLAUDE_ATT_KEY="$attempt_issue" node -e ' + const fs = require("fs"); + const key = process.env.CLAUDE_ATT_KEY; + try { + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const e = j[key] || {}; + console.log((e.attempts||0) + "\t" + (e.escalated?1:0)); + } catch (e) { console.log("0\t0"); } + ' "$attempts_file" 2>/dev/null || printf '0\t0')" + IFS=$'\t' read -r attempts_now attempts_escalated <<<"$attempt_read" + case "$attempts_now" in ''|*[!0-9]*) attempts_now=0 ;; esac + case "$attempts_escalated" in ''|*[!01]*) attempts_escalated=0 ;; esac + + if [ "$attempts_now" -ge "$per_issue_attempts" ]; then + ceiling_block="1"; ceiling_reason="attempt-budget" + echo "# pre-flight: attempt budget exceeded for issue=$attempt_issue ($attempts_now >= $per_issue_attempts)" + if [ "$attempts_escalated" != "1" ]; then + gh label create "needs-human" --color b60205 --description "Loop attempt budget exhausted -- needs a human" --force >/dev/null 2>&1 || true + body="This ${attempt_escalate_kind} has ping-ponged through $attempts_now advance/feedback dispatches for issue #$attempt_issue without landing (budget.per_issue_attempts=$per_issue_attempts). The loop will not retry it automatically -- labeling \`needs-human\`. Address it by hand, then either close it out or clear its entry in .claude/state/loop-issue-attempts.json to let the loop resume." + if [ "$attempt_escalate_kind" = "pr" ]; then + gh pr edit "$attempt_escalate_num" --add-label needs-human >/dev/null 2>&1 || true + gh pr comment "$attempt_escalate_num" --body "$body" >/dev/null 2>&1 || true + else + gh issue edit "$attempt_escalate_num" --add-label needs-human >/dev/null 2>&1 || true + gh issue comment "$attempt_escalate_num" --body "$body" >/dev/null 2>&1 || true + fi + tmp_att="$(mktemp "$state_dir/.loop-issue-attempts.json.XXXXXX")" + if CLAUDE_ATT_KEY="$attempt_issue" CLAUDE_ATT_COUNT="$attempts_now" node -e ' + const fs = require("fs"); + const file = process.argv[1], tmp = process.argv[2]; + const key = process.env.CLAUDE_ATT_KEY; + let j = {}; + try { j = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) {} + j[key] = { attempts: parseInt(process.env.CLAUDE_ATT_COUNT, 10) || 0, escalated: true }; + fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n"); + ' "$attempts_file" "$tmp_att" 2>/dev/null; then + mv -f "$tmp_att" "$attempts_file" + else + rm -f "$tmp_att" + fi + fi + fi +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. +# invariant that the verdict line is the LAST line of stdout. A spend-ceiling +# breach above (ceiling_block) short-circuits straight to action=none, +# WITHOUT the spawn-lock write the advance branch below would otherwise do. verdict="" -if [ -n "$feedback_pr" ]; then +if [ -n "$ceiling_block" ]; then + verdict="action=none" +elif [ -n "$feedback_pr" ]; then 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 @@ -284,10 +538,63 @@ else verdict="action=none" fi +# --- Spend-ceiling bookkeeping: increment counts on an ACTUAL dispatch ------ +# Only runs when the verdict just decided is a genuine advance/feedback +# dispatch (never on action=none, ceiling-blocked or not) -- so a blocked +# tick never itself grows the very counters that blocked it. +dispatch_issue="" +case "$verdict" in + "action=advance issue="*) dispatch_issue="${verdict#action=advance issue=}" ;; + "action=feedback pr="*) + dispatch_pr="${verdict#action=feedback pr=}" + if [ "$dispatch_pr" = "$feedback_pr" ] && [ -n "${feedback_issue:-}" ]; then + dispatch_issue="$feedback_issue" + else + dispatch_issue="$dispatch_pr" + fi + ;; +esac + +if [ -n "$dispatch_issue" ]; then + tmp_att="$(mktemp "$state_dir/.loop-issue-attempts.json.XXXXXX")" + if CLAUDE_ATT_KEY="$dispatch_issue" node -e ' + const fs = require("fs"); + const file = process.argv[1], tmp = process.argv[2]; + const key = process.env.CLAUDE_ATT_KEY; + let j = {}; + try { j = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) {} + const cur = j[key] || { attempts: 0, escalated: false }; + j[key] = { attempts: (cur.attempts || 0) + 1, escalated: !!cur.escalated }; + fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n"); + ' "$attempts_file" "$tmp_att" 2>/dev/null; then + mv -f "$tmp_att" "$attempts_file" + else + rm -f "$tmp_att" + fi + + tmp_daily="$(mktemp "$state_dir/.loop-daily-ceiling.json.XXXXXX")" + if CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const file = process.argv[1], tmp = process.argv[2]; + const today = process.env.CLAUDE_TODAY; + let j = {}; + try { j = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) {} + const count = (j.date === today) ? (j.count || 0) + 1 : 1; + fs.writeFileSync(tmp, JSON.stringify({ + date: today, count, halted: (j.date === today) ? !!j.halted : false, + issue_number: j.issue_number || null, + }, null, 2) + "\n"); + ' "$daily_file" "$tmp_daily" 2>/dev/null; then + mv -f "$tmp_daily" "$daily_file" + else + rm -f "$tmp_daily" + fi +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 +write_tick_record "$verdict" "$cadence" "$ceiling_reason" || true diff --git a/.claude/self/gates.json b/.claude/self/gates.json index 005581a..ee8d40e 100644 --- a/.claude/self/gates.json +++ b/.claude/self/gates.json @@ -36,7 +36,12 @@ "orchestrator_model": "opus", "worker_model": "sonnet", "explorer_model": "haiku", "reviewer_model": "sonnet", "reviewer_models": { "correctness": "opus" }, - "max_parallel_workers": 2 + "max_parallel_workers": 2, + + "_spend_ceilings_note": "issue #95 — loop spend ceilings, read by loop-tick.sh's STEP 0 pre-flight and arm-loop.sh's default --stop-after-days. Documented in docs/TOKEN_BUDGET.md -> 'Loop spend ceilings'.", + "stop_after_days": 7, + "per_issue_attempts": 5, + "daily_action_ceiling": 50 }, "merge": { "policy": "pr-per-agent", "baseBranch": "main" } diff --git a/docs/TOKEN_BUDGET.md b/docs/TOKEN_BUDGET.md index 3efd5ea..5204b82 100644 --- a/docs/TOKEN_BUDGET.md +++ b/docs/TOKEN_BUDGET.md @@ -29,7 +29,35 @@ each fire. The loop self-adjusts (STEP 0 in `/pr-loop`): FAST (1 min) only while IDLE (15 min) otherwise. A PR waiting on you does not burn a session a minute. If you'll be away for hours, consider disarming the cron entirely (`CronDelete`) and re-arming with `/pr-loop` when you're back. -## 4. Review iterations +## 4. Loop spend ceilings (issue #95) +Model routing and cadence bound the cost of a single tick; these three bound the loop's **aggregate** spend — +the failure mode where a pathological issue ping-pongs forever, or an armed loop is simply forgotten for +weeks (see [DoltHub's $3,000-in-a-week write-up](https://www.dolthub.com/blog/2026-03-24-a-week-in-gas-town/) +and [gh-aw's cost-management design](https://github.github.com/gh-aw/reference/cost-management/), which this +mirrors). All three are configurable via `.claude/gates.json` → `budget`, enforced by `loop-tick.sh`'s STEP 0 +pre-flight (before the verdict decision), and surfaced in the cockpit's "Loop health" panel. + +| Config key | Default | Effect on breach | +| --- | --- | --- | +| `budget.stop_after_days` | `7` | The armed loop self-disarms `armed_at + N` days after arming (`arm-loop.sh`, or `--stop-after-days` to override). Every tick after that emits `action=none reason=expired`, posts a ONE-TIME "loop disarmed" notice, and takes no further action until re-armed. | +| `budget.per_issue_attempts` | `5` | Per-issue advance/feedback dispatch budget — the anti-ping-pong bound. Once an issue (tracked across both its advance phase and its PR's feedback phase) has been dispatched this many times without landing, the tick refuses further dispatches (`reason=attempt-budget`), labels the PR/issue `needs-human`, and comments once explaining why. | +| `budget.daily_action_ceiling` | `50` | Dispatches (advance + feedback combined) allowed per UTC calendar day. On breach the loop halts for the rest of the day (`reason=daily-ceiling`), files (or refreshes) a single tracking issue, and resumes automatically at UTC midnight — counting resets by date, not by a timer. | + +State (gitignored, `.claude/state/`, atomic temp+`mv` writes like the rest of the loop's state): +- `loop-arming.json` — `{armed_at, expires_at, stop_after_days, notified_expired, notice_issue}`. Written by + `arm-loop.sh` on every arm/re-arm (which clears `notified_expired` and any prior expiry). If a loop was + armed before this feature existed and this file is missing, `loop-tick.sh` lazily creates one starting + *now* on its first tick, so an already-armed loop still gets a ceiling. +- `loop-issue-attempts.json` — `{"": {attempts, escalated}}`. Incremented on every genuine + advance/feedback dispatch for that issue; `escalated` guards the one-time `needs-human` label/comment. +- `loop-daily-ceiling.json` — `{date, count, halted, issue_number}`. `count`/`halted` reset automatically once + `date` no longer matches today; `issue_number` (the filed tracking issue) persists across the reset so a + later-day re-breach refreshes the same issue instead of filing a duplicate. + +All gh side effects here (notify/label/comment/file-issue) go through `bot-gh.sh`, same as the rest of the +loop, and are best-effort — a failure never breaks a tick, it just means the human misses a notification. + +## 5. Review iterations - Re-reviews only re-run the lenses that **rejected** — an approval stands (orchestrator rule + `feature-fanout.js` v2). With 4 lenses and 1 rejection, iteration 2 costs 1 review, not 4. - `MAX_ITERS` caps the loop so a stubborn sub-task can't spiral. @@ -37,7 +65,7 @@ consider disarming the cron entirely (`CronDelete`) and re-arming with `/pr-loop - Cap parallelism: `max_parallel_workers` bounds concurrent implementers (default 2). More workers ≈ more tokens *and* more for you to review. -## 5. Context hygiene +## 6. Context hygiene - **Long conversations are the silent cost.** Context is re-sent (cached, but not free) with every message, and each turn makes the next one dearer. `/clear` between unrelated tasks; `/rename` first so `/resume` can find the session later. `/compact ` when you must keep going in-place. @@ -53,12 +81,12 @@ consider disarming the cron entirely (`CronDelete`) and re-arming with `/pr-loop - Disable MCP servers you aren't using (`/mcp`); prefer CLIs (`gh`, `aws`, …) over MCP equivalents — they add zero per-tool context. -## 6. Thinking & effort +## 7. Thinking & effort Extended thinking is billed as output tokens and defaults generous. For routine work, lower it with `/effort` (or in `/model`); on fixed-budget models, `MAX_THINKING_TOKENS=8000` in the environment. Keep full effort for the orchestrator's scoping and the correctness/security reviews — that's where reasoning pays. -## 7. Workflow budget guard +## 8. Workflow budget guard The workflow engine exposes a token budget. When you set a target (e.g. type `+500k` style directives), scripts can scale fan-out / loop depth and HARD-STOP at the ceiling. From 9fdd23674e6d46cf587b917acee9cff749585c86 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:33:17 +0200 Subject: [PATCH 2/2] fix(loop): strengthen ceiling-notify label assertions, cover issue reuse path TESTS reviewer rejected two blocking coverage gaps on the spend-ceilings change (#95): 1. loop-ceilings.test.sh's stubbed-gh assertions for the ceiling notify path only checked `grep -q "^issue create"`, never the `--label backlog` argument. A silent regression to `--label planned` (or a dropped label) would go undetected, and since loop-census.sh treats `planned`-labeled issues as work, that regression would make the loop treat its own budget-exceeded notice as a new work item. Scenarios 2 and 8 now assert the FULL expected `issue create` call including `--label backlog`, plus a negative assertion that `--label planned` is never emitted. 2. budget_notify_issue()'s reuse path (loop-tick.sh:313-318 -- `gh issue view` an existing tracked issue and comment if OPEN, or file fresh if CLOSED) had zero coverage. New scenario 10 drives three ticks against one fixture: first breach files a tracking issue, second breach (issue OPEN) comments on it instead of duplicating, third breach (issue CLOSED) files a fresh one. The fake bot-gh.sh's `issue view` reply is now configurable via FAKE_ISSUE_STATE. Also added a CLAUDE_TODAY override in loop-tick.sh (mirrors cockpit.sh's COCKPIT_NOW pattern), defaulting to `date -u +%Y-%m-%d` when unset, and wired it through the date-sensitive test scenarios (7, 8, 9, 10) to close a narrow UTC-midnight flake window where the test and the script could independently compute a different calendar date. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/loop-ceilings.test.sh | 87 +++++++++++++++++++++++++-- .claude/scripts/loop-tick.sh | 8 ++- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh index ea5c0dd..17cb1f4 100644 --- a/.claude/scripts/loop-ceilings.test.sh +++ b/.claude/scripts/loop-ceilings.test.sh @@ -162,7 +162,8 @@ out2="$(run_tick "$dir2")" check "scenario 2 (past expiry): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out2" check "scenario 2: diagnostic cites the expiry" bash -c 'printf "%s\n" "$1" | grep -q "loop disarmed (stop-after expired"' _ "$out2" check "scenario 2: exactly one gh call was made (the one-time notify)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir2" -check "scenario 2: the notify call was 'issue create'" bash -c 'gh_calls "$1" | grep -q "^issue create"' _ "$dir2" +check "scenario 2: the notify call is the FULL expected 'issue create' with --label backlog (not just any create)" bash -c 'gh_calls "$1" | grep -qF -- "issue create --title Loop disarmed: stop-after expired --label backlog --body "' _ "$dir2" +check "scenario 2: --label planned is NEVER emitted by the ceiling notify path (self-loop regression guard)" bash -c '! gh_calls "$1" | grep -q -- "--label planned"' _ "$dir2" check "scenario 2: notified_expired is now persisted true" bash -c ' node -e "const j=require(process.argv[1]); if(j.notified_expired!==true) process.exit(1); if(j.notice_issue!==777) process.exit(1);" "$1/../state/loop-arming.json" ' _ "$dir2" @@ -258,7 +259,7 @@ CLAUDE_TODAY="$today" node -e ' fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: process.env.CLAUDE_TODAY, count: 10, halted: false, issue_number: null })); ' "$dir7" -out7="$(run_tick "$dir7")" +out7="$(CLAUDE_TODAY="$today" run_tick "$dir7")" check "scenario 7 (count 10 < ceiling 50): advance proceeds" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out7" check "scenario 7: daily count incremented from 10 to 11" env CLAUDE_TODAY="$today" node -e ' const fs = require("fs"); @@ -278,16 +279,18 @@ CLAUDE_TODAY="$today" node -e ' fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: process.env.CLAUDE_TODAY, count: 50, halted: false, issue_number: null })); ' "$dir8" -out8="$(run_tick "$dir8")" +out8="$(CLAUDE_TODAY="$today" run_tick "$dir8")" check "scenario 8 (count 50 >= ceiling 50): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out8" check "scenario 8: diagnostic cites the daily ceiling" bash -c 'printf "%s\n" "$1" | grep -q "daily action ceiling reached"' _ "$out8" check "scenario 8: exactly one gh call (the tracking-issue file)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir8" +check "scenario 8: the notify call is the FULL expected 'issue create' with --label backlog (not just any create)" bash -c 'gh_calls "$1" | grep -qF -- "issue create --title Loop budget exceeded: daily action ceiling --label backlog --body "' _ "$dir8" +check "scenario 8: --label planned is NEVER emitted by the ceiling notify path (self-loop regression guard)" bash -c '! gh_calls "$1" | grep -q -- "--label planned"' _ "$dir8" check "scenario 8: halted persisted true with the filed issue number recorded" node -e ' const fs = require("fs"); const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (j.halted !== true || j.issue_number !== 777) throw new Error("got " + JSON.stringify(j)); ' "$dir8/../state/loop-daily-ceiling.json" -out8b="$(run_tick "$dir8")" +out8b="$(CLAUDE_TODAY="$today" run_tick "$dir8")" check "scenario 8b (still halted, second same-day tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out8b" check "scenario 8b: no additional gh call (halted guard held) -- still exactly 1" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ]' _ "$dir8" @@ -302,7 +305,7 @@ node -e ' fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: "2020-01-01", count: 999, halted: true, issue_number: 555 })); ' "$dir9" -out9="$(run_tick "$dir9")" +out9="$(CLAUDE_TODAY="$today" run_tick "$dir9")" check "scenario 9 (stale date from a prior day): advance proceeds -- counter reset" bash -c '[ "$(verdict_of "$1")" = "action=advance issue=42" ]' _ "$out9" check "scenario 9: today's count is now 1 (fresh day), issue_number 555 preserved for future refresh" env CLAUDE_TODAY="$today" node -e ' const fs = require("fs"); @@ -311,6 +314,80 @@ check "scenario 9: today's count is now 1 (fresh day), issue_number 555 preserve ' "$dir9/../state/loop-daily-ceiling.json" check "scenario 9: no gh calls (a normal dispatch never itself calls gh)" bash -c '[ -z "$(gh_calls "$1")" ]' _ "$dir9" +# --------------------------------------------------------------------------- +# 10. budget_notify_issue() reuse/refresh path (loop-tick.sh:313-318), which +# had ZERO coverage before this scenario: a SECOND breach after an issue +# is already on file must NOT file a duplicate -- it must `gh issue view` +# the tracked issue and either comment on it (still OPEN) or file a fresh +# one (CLOSED). Drives three ticks against the SAME fixture, manually +# resetting `halted` back to false between them to simulate independent +# breach events without grinding through 50 real ticks per UTC day +# (halted is what suppresses gh calls WITHIN a single breach -- see +# scenario 8 -- so clearing it directly is how this isolates the reuse +# branch on demand). The fake bot-gh.sh's `issue view` reply is +# configurable via FAKE_ISSUE_STATE (see new_fixture above). CLAUDE_TODAY +# pins "today" across every tick so none of this depends on wall-clock +# date (loop-tick.sh's CLAUDE_TODAY override was added alongside this +# coverage -- see loop-tick.sh:408). +# --------------------------------------------------------------------------- +dir10="$(new_fixture scenario10 "$CENSUS_READY_42" "" 1)" +CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-daily-ceiling.json", JSON.stringify({ date: process.env.CLAUDE_TODAY, count: 50, halted: false, issue_number: null })); +' "$dir10" + +# --- 10a: first breach files a fresh tracking issue (no existing to reuse) -- +out10a="$(CLAUDE_TODAY="$today" run_tick "$dir10")" +check "scenario 10a (first breach, no existing issue): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out10a" +check "scenario 10a: exactly one gh call, a full 'issue create' with --label backlog" bash -c ' + [ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 1 ] && + gh_calls "$1" | grep -qF -- "issue create --title Loop budget exceeded: daily action ceiling --label backlog --body " +' _ "$dir10" +check "scenario 10a: issue_number 777 now tracked" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j.issue_number !== 777) throw new Error("got " + JSON.stringify(j)); +' "$dir10/../state/loop-daily-ceiling.json" + +# --- 10b: SECOND breach, tracked issue still OPEN -> comment, no duplicate -- +CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const file = process.argv[1] + "/../state/loop-daily-ceiling.json"; + const j = JSON.parse(fs.readFileSync(file, "utf8")); + j.halted = false; // simulate a fresh breach event; tracking issue preserved + fs.writeFileSync(file, JSON.stringify(j)); +' "$dir10" +out10b="$(CLAUDE_TODAY="$today" FAKE_ISSUE_STATE=OPEN run_tick "$dir10")" +check "scenario 10b (second breach, tracked issue OPEN): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out10b" +check "scenario 10b: no duplicate 'issue create' (still exactly 1 total) -- instead 'issue view' then 'issue comment 777'" bash -c ' + [ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 3 ] && + [ "$(gh_calls "$1" | grep -c "^issue create")" -eq 1 ] && + gh_calls "$1" | grep -qF -- "issue view 777 --json state --jq .state" && + gh_calls "$1" | grep -qF -- "issue comment 777 --body " +' _ "$dir10" +check "scenario 10b: issue_number still 777 (reused, not replaced)" node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (j.issue_number !== 777) throw new Error("got " + JSON.stringify(j)); +' "$dir10/../state/loop-daily-ceiling.json" + +# --- 10c: THIRD breach, tracked issue now CLOSED -> a fresh issue is filed -- +CLAUDE_TODAY="$today" node -e ' + const fs = require("fs"); + const file = process.argv[1] + "/../state/loop-daily-ceiling.json"; + const j = JSON.parse(fs.readFileSync(file, "utf8")); + j.halted = false; + fs.writeFileSync(file, JSON.stringify(j)); +' "$dir10" +out10c="$(CLAUDE_TODAY="$today" FAKE_ISSUE_STATE=CLOSED run_tick "$dir10")" +check "scenario 10c (third breach, tracked issue CLOSED): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out10c" +check "scenario 10c: a SECOND 'issue create' fires (closed tracked issue is not reused); comment count still 1" bash -c ' + [ "$(gh_calls "$1" | grep -c "^issue create")" -eq 2 ] && + [ "$(gh_calls "$1" | grep -c "^issue comment")" -eq 1 ] +' _ "$dir10" + echo "" if [ "$fail" -eq 0 ]; then echo "loop-ceilings.test.sh: PASS ($ok checks)" diff --git a/.claude/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh index 6abeef7..eccdec4 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -405,7 +405,13 @@ fi # issue_number persists ACROSS the reset so a re-breach on a later day # refreshes the same tracking issue instead of filing a duplicate. daily_file="$state_dir/loop-daily-ceiling.json" -today="$(date -u +%Y-%m-%d)" +# CLAUDE_TODAY (mirrors cockpit.sh's COCKPIT_NOW override pattern): lets tests +# pin "today" instead of relying on `date -u` at the exact instant this script +# runs -- without it there's a narrow UTC-midnight race between a test writing +# daily-ceiling fixture state and this script reading it moments later, where +# the two could disagree on the calendar date. Unset/empty in production (and +# in every real invocation) -> falls back to the real UTC date, unchanged. +today="${CLAUDE_TODAY:-$(date -u +%Y-%m-%d)}" daily_read="$(node -e ' const fs = require("fs"); let j = {};