Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,10 @@ jobs:
tests-herdr:
name: Behavior tests (Herdr)
runs-on: ubuntu-latest
# Real Herdr is slower than the portable suite; this is a hang tripwire,
# not the expected healthy end of the lane (estimate 15-40 min first cut).
# Healthy runs finish around 7 minutes. This job cap is a last-resort hang
# tripwire, not the expected end of the lane. The family-run step owns the
# tighter bound so a wedged suite fails fast with always() cleanup and
# timing artifacts still uploaded (docs/fm-test-portable-shards.md).
timeout-minutes: 75
steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -252,6 +254,9 @@ jobs:
mkdir -p "$RUNNER_TEMP/fm-herdr"
bin/fm-herdr-ci-cleanup.sh snapshot "$RUNNER_TEMP/fm-herdr/sessions-before.json"
- name: Run real-Herdr family (serial, required)
# Comfortably above the ~7 min healthy wall and far below the 75 min
# job backstop. A hang must fail this step so cleanup still runs.
timeout-minutes: 20
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
Expand Down
130 changes: 130 additions & 0 deletions bin/fm-agy-quota-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# fm-agy-quota-lib.sh - Quota memory observation and read helpers.
# Usage: . bin/fm-agy-quota-lib.sh

# fm_agy_parse_reset_time: converts '4h 24m', '1d 2h', etc to seconds.
fm_agy_parse_reset_time() {
local ts="$1"
local total=0
local d=0 h=0 m=0 s=0

case "$ts" in
*d*) d="${ts%%d*}"; d="${d##* }"; total=$((total + d * 86400)) ;;
esac
case "$ts" in
*h*) h="${ts%%h*}"; h="${h##* }"; total=$((total + h * 3600)) ;;
esac
case "$ts" in
*m*) m="${ts%%m*}"; m="${m##* }"; total=$((total + m * 60)) ;;
esac
case "$ts" in
*s*) s="${ts%%s*}"; s="${s##* }"; total=$((total + s)) ;;
esac
echo "$total"
}

# fm_agy_quota_observe: extract and record quota from agy pane footer.
# Format: Gemini 3.1 Pro (High) | ctx: 10.5% | quota: 94.7% (4h 24m)
fm_agy_quota_observe() { # <text> <state_dir>
local text="$1" state_dir="$2"
case "$text" in
*" | quota: "*) ;;
*) return 0 ;;
esac

local pre="${text%% | quota: *}"
local nl='
'
local line_start="${pre##*"$nl"}"
local model="${line_start%% | ctx: *}"
if [ "$model" = "$line_start" ]; then
return 0
fi

local post="${text#* | quota: }"
local line_end="${post%%"$nl"*}"
local quota="${line_end%% (*}"
local reset_time="${line_end#*(}"
reset_time="${reset_time%)}"

if [ -z "$model" ] || [ -z "$quota" ] || [ -z "$reset_time" ]; then
return 0
fi

local safe_model
if command -v md5 >/dev/null 2>&1; then
safe_model=$(printf '%s' "$model" | md5 -q)
else
safe_model=$(printf '%s' "$model" | md5sum | cut -d' ' -f1)
fi

local now
now=$(date +%s)

# Only write if it actually changed, but doing this requires reading.
# Since this is already conditionally executed, writing directly is fine.
printf '%s|%s|%s|%s\n' "$model" "$quota" "$reset_time" "$now" > "$state_dir/.agy-quota-$safe_model"
}

# fm_agy_quota_read: returns the last known value for a model together with its age.
fm_agy_quota_read() { # <model> <state_dir> [<now>]
local model="$1" state_dir="$2" now_override="${3:-}"
local safe_model
if command -v md5 >/dev/null 2>&1; then
safe_model=$(printf '%s' "$model" | md5 -q)
else
safe_model=$(printf '%s' "$model" | md5sum | cut -d' ' -f1)
fi

local file="$state_dir/.agy-quota-$safe_model"
if [ ! -f "$file" ]; then
echo "unknown"
return 0
fi

local line
line=$(cat "$file" 2>/dev/null || true)
if [ -z "$line" ]; then
echo "unknown"
return 0
fi

local file_model="${line%%|*}"
local rest="${line#*|}"
local quota="${rest%%|*}"
rest="${rest#*|}"
local reset_time_str="${rest%%|*}"
local obs_ts="${rest#*|}"

if [ "$file_model" != "$model" ] || [ -z "$quota" ] || [ -z "$reset_time_str" ] || [ -z "$obs_ts" ]; then
echo "unknown"
return 0
fi

local now
if [ -n "$now_override" ]; then
now="$now_override"
else
now=$(date +%s)
fi

local age=$((now - obs_ts))
if [ "$age" -lt 0 ]; then
age=0
fi

local reset_seconds
reset_seconds=$(fm_agy_parse_reset_time "$reset_time_str")
if [ "$reset_seconds" -eq 0 ]; then
echo "unknown"
return 0
fi

if [ "$age" -ge "$reset_seconds" ]; then
echo "unknown"
return 0
fi

quota="${quota%%%*}"
echo "$quota $age"
}
5 changes: 5 additions & 0 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ mkdir -p "$STATE"
. "$SCRIPT_DIR/fm-pending-reply-lib.sh"
# shellcheck source=bin/fm-busy-lib.sh
. "$SCRIPT_DIR/fm-busy-lib.sh"
# shellcheck source=bin/fm-agy-quota-lib.sh
. "$SCRIPT_DIR/fm-agy-quota-lib.sh"

WATCH_LOCK="$STATE/.watch.lock"
WATCH_PATH="$SCRIPT_DIR/fm-watch.sh"
Expand Down Expand Up @@ -1033,6 +1035,9 @@ EOF
# content cannot suppress stale detection. Read once per window per poll and
# reused below so a busy verdict is consistent within one cycle.
if window_is_busy "$w" "$tail40"; then busy_now=0; else busy_now=1; fi
if [ "$h" != "$prev" ]; then
fm_agy_quota_observe "$tail40" "$STATE"
fi
if [ "$h" = "$prev" ]; then
n=$(( $(cat "$cf" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$cf"
Expand Down
11 changes: 6 additions & 5 deletions docs/fm-test-portable-shards.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ Portable shards, each portable serial shard, and the Herdr lane upload runner-ge

## Timeouts

| Job | timeout-minutes | Rationale |
|---|---:|---|
| portable parallel 1/2 | 10 | The measured shard sums are about three minutes and the timeout is a hang tripwire. |
| portable serial 1-4 | 15 | Each balanced shard is about five minutes, leaving roughly 3x hang-tripwire margin. |
| Herdr | 40 | The real-Herdr lane keeps its dedicated timeout. |
| Lane | Bound | Rationale |
|---|---|---|
| portable parallel 1/2 | job `timeout-minutes: 10` | The measured shard sums are about three minutes and the timeout is a hang tripwire. |
| portable serial 1-4 | job `timeout-minutes: 15` | Each balanced shard is about five minutes, leaving roughly 3x hang-tripwire margin. |
| Herdr | family-run step `timeout-minutes: 20`; job `timeout-minutes: 75` backstop | Healthy runs finish around 7 minutes, so the step bound is the hang tripwire (cleanup and timing artifacts still upload) while the job cap stays a last-resort backstop. |

Timeouts are hang tripwires rather than expected healthy durations.
`.github/workflows/ci.yml` owns the exact numbers.
91 changes: 91 additions & 0 deletions tests/fm-agy-quota-lib.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
# shellcheck source=bin/fm-agy-quota-lib.sh
. "$ROOT/bin/fm-agy-quota-lib.sh"

TMP_ROOT=$(fm_test_tmproot fm-agy-quota-lib)

assert_eq() {
local expected=$1 actual=$2
if [ "$expected" != "$actual" ]; then
fail "expected '$expected' but got '$actual'"
fi
}

echo "Testing fm_agy_parse_reset_time"
assert_eq "15840" "$(fm_agy_parse_reset_time '4h 24m')"
assert_eq "2700" "$(fm_agy_parse_reset_time '45m')"
assert_eq "93600" "$(fm_agy_parse_reset_time '1d 2h')"
assert_eq "30" "$(fm_agy_parse_reset_time '30s')"
pass "fm_agy_parse_reset_time logic works"

echo "Testing observe and read valid"
state="$TMP_ROOT/state1"
mkdir -p "$state"

footer="Gemini 3.1 Pro (High) | ctx: 10.5% | quota: 94.7% (4h 24m)"
fm_agy_quota_observe "$footer" "$state"

now=$(date +%s)
res=$(fm_agy_quota_read "Gemini 3.1 Pro (High)" "$state" "$now")
assert_eq "94.7 0" "$res"

# Advance time by 10 seconds
res=$(fm_agy_quota_read "Gemini 3.1 Pro (High)" "$state" "$((now + 10))")
assert_eq "94.7 10" "$res"
pass "observe and read valid handles normal cases"

echo "Testing read older than reset window"
state="$TMP_ROOT/state2"
mkdir -p "$state"

footer="Claude Opus 4.6 (Thinking) | ctx: 5% | quota: 14.7% (1h 0m)"
fm_agy_quota_observe "$footer" "$state"

now=$(date +%s)

# 3600 seconds is the window.
res=$(fm_agy_quota_read "Claude Opus 4.6 (Thinking)" "$state" "$now")
assert_eq "14.7 0" "$res"

# 3600 seconds later -> reset
res=$(fm_agy_quota_read "Claude Opus 4.6 (Thinking)" "$state" "$((now + 3600))")
assert_eq "unknown" "$res"
pass "older than window returns unknown"

echo "Testing missing, unparseable, ambiguous"
state="$TMP_ROOT/state3"
mkdir -p "$state"

# Missing
res=$(fm_agy_quota_read "No Such Model" "$state" "$(date +%s)")
assert_eq "unknown" "$res"

# Unparseable reset time
footer="Bad Model | ctx: 0% | quota: 50% (forever)"
fm_agy_quota_observe "$footer" "$state"
res=$(fm_agy_quota_read "Bad Model" "$state" "$(date +%s)")
assert_eq "unknown" "$res"

# Ambiguous formatting (missing ctx but has quota, observe will reject it)
footer="Ambiguous Model | quota: 50% (1h)"
fm_agy_quota_observe "$footer" "$state"
res=$(fm_agy_quota_read "Ambiguous Model" "$state" "$(date +%s)")
assert_eq "unknown" "$res"
pass "missing unparseable ambiguous fail open"

echo "Testing set -u compliance with 2 arguments"
state="$TMP_ROOT/state4"
mkdir -p "$state"
footer="Model Two Args | ctx: 10% | quota: 50% (1h)"
fm_agy_quota_observe "$footer" "$state"
# This call must not crash under set -u
res=$(fm_agy_quota_read "Model Two Args" "$state")
# It should successfully read the value (age will be 0 as it was just observed)
assert_eq "50 0" "$res"
pass "set -u compliance with 2 arguments works"

echo "ALL TESTS PASSED"
35 changes: 35 additions & 0 deletions tests/fm-test-run.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,40 @@ SH
pass "jobs scheduler runs proven scripts; failure propagates; non-proven refused"
}

test_herdr_ci_family_run_has_a_step_timeout() {
# The required Herdr lane's hang tripwire is the family-run *step* bound, not
# the 75-minute job cap. Parse the workflow as YAML so nested `with.name`
# artifact keys cannot masquerade as the step contract.
command -v ruby >/dev/null 2>&1 \
|| fail "ruby is required to parse .github/workflows/ci.yml as YAML"
local json job_timeout step_timeout
json=$(ruby -ryaml -rjson -e '
doc = YAML.load_file(ARGV[0])
job = doc.fetch("jobs").fetch("tests-herdr")
step = job.fetch("steps").find { |s|
s.is_a?(Hash) && s["name"] == "Run real-Herdr family (serial, required)"
}
raise "missing family-run step" if step.nil?
raise "family-run step has no timeout-minutes" unless step.key?("timeout-minutes")
puts JSON.generate(
"job_timeout" => job.fetch("timeout-minutes"),
"step_timeout" => step.fetch("timeout-minutes")
)
' "$ROOT/.github/workflows/ci.yml") \
|| fail "could not parse tests-herdr timeouts from ci.yml"
job_timeout=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["job_timeout"])' <<<"$json") \
|| fail "could not read job timeout from parsed workflow"
step_timeout=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["step_timeout"])' <<<"$json") \
|| fail "could not read step timeout from parsed workflow"
[ "$job_timeout" = 75 ] \
|| fail "tests-herdr job backstop must stay 75 minutes, got $job_timeout"
[ "$step_timeout" = 20 ] \
|| fail "family-run step timeout must be 20 minutes, got $step_timeout"
[ "$step_timeout" -lt "$job_timeout" ] \
|| fail "family-run step timeout must be below the job backstop"
pass "Herdr CI family-run step times out at 20 min under a 75 min job backstop"
}

test_aggregate_json() {
local tmp a b
tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-aggjson.XXXXXX")
Expand Down Expand Up @@ -685,4 +719,5 @@ test_portable_serial_shards_partition_the_serial_lane
test_portable_serial_shard_lane_refusals
test_jobs_requires_proven_isolated
test_jobs_parallel_scheduler_and_failure_propagation
test_herdr_ci_family_run_has_a_step_timeout
test_aggregate_json
Loading