From 94d50ae290bdf833aa258650761269ce780deefd Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Mon, 5 Oct 2026 23:33:46 +0000 Subject: [PATCH 1/3] feat(blocks-api): add throttled self-update freshness check Add scripts/skill-freshness-check.sh to the blocks-api skill. The agent runs it once per invocation; it refreshes the installed skill via the skills CLI at most once a day, retries failures after 15 minutes, backs off to daily after three failures in a row, and always exits 0. It prints a single note only when the skill files actually changed. The update runs from the directory that owns the .agents tree so the CLI sees both global and project installs, and the skill contents are checksummed before and after because the CLI reports "Updated" for project installs even when nothing changed. Also add a bash test harness for the script, run it and shellcheck in CI, document the opt-out in the README, and bump the skill to 0.2.0. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 7 +- README.md | 2 + skills/blocks-api/SKILL.md | 12 +- .../scripts/skill-freshness-check.sh | 197 +++++++++++++++++ .../scripts/skill-freshness-check.test.sh | 198 ++++++++++++++++++ 5 files changed, 414 insertions(+), 2 deletions(-) create mode 100755 skills/blocks-api/scripts/skill-freshness-check.sh create mode 100755 skills/blocks-api/scripts/skill-freshness-check.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9d4d34..6e97ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,8 @@ jobs: # Every skill must be discoverable by `npx skills add`, its frontmatter name # must match its directory, and nothing in the repo may leak a real key or an - # internal host. The install script must at least parse. + # internal host. The install script must at least parse, every shell script + # must pass shellcheck, and the skill scripts' own tests must pass. validate: name: Validate skills runs-on: ubuntu-latest @@ -88,3 +89,7 @@ jobs: fi - name: install.sh parses run: sh -n install.sh + - name: shellcheck scripts + run: shellcheck install.sh skills/*/scripts/*.sh + - name: blocks-api freshness check tests + run: bash skills/blocks-api/scripts/skill-freshness-check.test.sh diff --git a/README.md b/README.md index f52727e..bae7365 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ npx skills add https://github.com/BlocksOrg/skills --skill blocks-api --global Omit `--global` to install into the current project instead of your home directory. +Once installed, the skill refreshes itself from this repo at most once a day the first time it is used. Set `BLOCKS_SKILLS_NO_UPDATE=1` to disable that. + One-liner alternative: ```bash diff --git a/skills/blocks-api/SKILL.md b/skills/blocks-api/SKILL.md index c360424..4fc385f 100644 --- a/skills/blocks-api/SKILL.md +++ b/skills/blocks-api/SKILL.md @@ -8,9 +8,19 @@ description: > Blocks agent did in a session. Requires BLOCKS_API_KEY. license: MIT metadata: - version: "0.1.0" + version: "0.2.0" --- +## Step 0: freshness check + +Before anything else, run the skill's own update check once, resolving the path relative to this file's directory: + +```bash +bash scripts/skill-freshness-check.sh +``` + +It refreshes this skill from its source at most once a day, never prompts, and always exits 0. If it prints a note, relay it to the user (the refreshed instructions take effect next session). If it prints nothing, say nothing. Continue with the task regardless of the outcome. Set `BLOCKS_SKILLS_NO_UPDATE=1` to disable it. + ## Setup ```bash diff --git a/skills/blocks-api/scripts/skill-freshness-check.sh b/skills/blocks-api/scripts/skill-freshness-check.sh new file mode 100755 index 0000000..e3eb2e9 --- /dev/null +++ b/skills/blocks-api/scripts/skill-freshness-check.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Keep the installed blocks-api skill fresh without getting in the agent's way. +# +# The agent runs this once per invocation of the skill. The script decides +# whether an update is due, runs `npx skills update` from the right directory, +# and reports a single line on stdout only when the skill files actually +# changed. It never fails: every path exits 0, so a broken network or a +# missing npx can never block the task the agent was asked to do. +# +# Throttling (per scope root, tracked in a small marker file): +# - after a successful check, stay quiet for 24 hours +# - after a failed check, retry 15 minutes later +# - after 3 consecutive failures, back off to one attempt per day +# +# Environment: +# BLOCKS_SKILLS_NO_UPDATE=1 opt out entirely (exit 0 immediately) +# BLOCKS_SKILLS_DEBUG=1 log every decision to stderr +# BLOCKS_SKILLS_NOW override the clock (epoch seconds), for tests +# BLOCKS_SKILLS_UPDATE_CMD override the update command, for tests +# BLOCKS_SKILLS_ROOT override the detected scope root, for tests +# XDG_CACHE_HOME marker files live under $XDG_CACHE_HOME/blocks-skills +# (defaults to ~/.cache) + +SKILL_NAME="blocks-api" +SKILLS_CLI_VERSION="1.7.0" + +SUCCESS_INTERVAL=86400 # 24h: do not re-check after a success +RETRY_INTERVAL=900 # 15m: retry after a failure +MAX_FAILURES=3 # after this many failures in a row, retry daily +UPDATE_TIMEOUT=120 # seconds; only enforced where `timeout` exists + +log() { + if [ -n "${BLOCKS_SKILLS_DEBUG:-}" ]; then + printf 'skill-freshness-check: %s\n' "$*" >&2 + fi +} + +if [ -n "${BLOCKS_SKILLS_NO_UPDATE:-}" ]; then + log "BLOCKS_SKILLS_NO_UPDATE is set; skipping" + exit 0 +fi + +# --- locate ourselves --------------------------------------------------------- +# `pwd -P` resolves symlinks, so being invoked through ~/.claude/skills/ +# still lands on the real ~/.agents/skills/ directory. +script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd -P) || exit 0 +skill_dir=$(cd "$script_dir/.." 2>/dev/null && pwd -P) || exit 0 + +# The skills CLI reads project locks from the current directory and global +# locks from the home directory. Walk up from the skill until we hit the +# directory that owns the `.agents/` (or `.claude/`) tree; that is where +# `npx skills update` must run so both scopes are considered. +find_root() { + dir=$skill_dir + while [ "$dir" != "/" ] && [ -n "$dir" ]; do + dir=$(dirname "$dir") + if [ -d "$dir/.agents" ] || [ -d "$dir/.claude" ]; then + printf '%s\n' "$dir" + return 0 + fi + done + return 1 +} + +if [ -n "${BLOCKS_SKILLS_ROOT:-}" ]; then + root=$BLOCKS_SKILLS_ROOT +elif ! root=$(find_root); then + root=${HOME:-} +fi +if [ -z "$root" ] || [ ! -d "$root" ]; then + log "could not determine a scope root; skipping" + exit 0 +fi +log "skill dir: $skill_dir" +log "scope root: $root" + +# --- marker file -------------------------------------------------------------- +cache_home=${XDG_CACHE_HOME:-${HOME:-}/.cache} +if [ -z "${XDG_CACHE_HOME:-}" ] && [ -z "${HOME:-}" ]; then + log "neither XDG_CACHE_HOME nor HOME is set; skipping" + exit 0 +fi +root_hash=$(printf '%s' "$root" | cksum | cut -d' ' -f1) +marker_dir="$cache_home/blocks-skills/$root_hash" +marker="$marker_dir/$SKILL_NAME" +if ! mkdir -p "$marker_dir" 2>/dev/null; then + log "cannot create $marker_dir; skipping" + exit 0 +fi + +now=${BLOCKS_SKILLS_NOW:-$(date +%s)} +case $now in + ''|*[!0-9]*) log "invalid clock value '$now'; skipping"; exit 0 ;; +esac + +last_success=0 +last_attempt=0 +failures=0 +if [ -f "$marker" ]; then + while IFS='=' read -r key value; do + case $value in ''|*[!0-9]*) continue ;; esac + case $key in + last_success) last_success=$value ;; + last_attempt) last_attempt=$value ;; + failures) failures=$value ;; + esac + done < "$marker" +fi +log "marker: $marker (last_success=$last_success last_attempt=$last_attempt failures=$failures)" + +write_marker() { + tmp="$marker.$$.tmp" + { + printf 'last_success=%s\n' "$last_success" + printf 'last_attempt=%s\n' "$last_attempt" + printf 'failures=%s\n' "$failures" + } > "$tmp" 2>/dev/null && mv -f "$tmp" "$marker" 2>/dev/null + rm -f "$tmp" 2>/dev/null +} + +# --- is an update due? -------------------------------------------------------- +if [ $((now - last_success)) -lt $SUCCESS_INTERVAL ]; then + log "checked successfully $((now - last_success))s ago; not due" + exit 0 +fi + +backoff=$RETRY_INTERVAL +if [ "$failures" -ge $MAX_FAILURES ]; then + backoff=$SUCCESS_INTERVAL +fi +if [ $((now - last_attempt)) -lt $backoff ]; then + log "last attempt was $((now - last_attempt))s ago (backoff ${backoff}s, failures=$failures); not due" + exit 0 +fi + +# --- run the update ----------------------------------------------------------- +fingerprint() { + ( + cd "$skill_dir" 2>/dev/null || exit 0 + for f in SKILL.md references/*.md scripts/*.sh; do + [ -f "$f" ] && cksum "$f" + done | cksum + ) +} + +update_cmd=${BLOCKS_SKILLS_UPDATE_CMD:-"npx --yes skills@$SKILLS_CLI_VERSION update $SKILL_NAME -y"} +if [ -z "${BLOCKS_SKILLS_UPDATE_CMD:-}" ] && ! command -v npx >/dev/null 2>&1; then + log "npx not found on PATH; skipping" + exit 0 +fi + +runner="" +if command -v timeout >/dev/null 2>&1; then + runner="timeout $UPDATE_TIMEOUT" +fi + +last_attempt=$now +write_marker + +before=$(fingerprint) +log "running: $update_cmd (in $root)" +# shellcheck disable=SC2086 # $runner is intentionally word-split +output=$(cd "$root" && $runner sh -c "$update_cmd" 2>&1 &2 +fi + +# The CLI exits 0 for most of its own error paths, so look at the output too. +failed=0 +if [ "$rc" -ne 0 ]; then + failed=1 +elif printf '%s\n' "$output" | grep -qiE 'failed|cannot update|npm err|error'; then + failed=1 +fi + +if [ "$failed" -eq 1 ]; then + failures=$((failures + 1)) + write_marker + log "update failed (failures=$failures)" + exit 0 +fi + +last_success=$now +failures=0 +write_marker + +if [ "$before" != "$after" ]; then + log "skill files changed" + printf 'Note: the %s skill was refreshed to the latest published version. The updated instructions take effect in your next session.\n' "$SKILL_NAME" +else + log "skill already up to date" +fi +exit 0 diff --git a/skills/blocks-api/scripts/skill-freshness-check.test.sh b/skills/blocks-api/scripts/skill-freshness-check.test.sh new file mode 100755 index 0000000..a779e05 --- /dev/null +++ b/skills/blocks-api/scripts/skill-freshness-check.test.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Tests for skill-freshness-check.sh. +# +# Copies the skill into a throwaway project layout (/.agents/skills/ +# blocks-api) so the script resolves its scope root the same way it does after +# a real `npx skills add`, then drives it with a fake clock and a fake update +# command. Run with: bash skills/blocks-api/scripts/skill-freshness-check.test.sh +set -u + +here=$(cd "$(dirname "$0")" && pwd -P) +skill_src=$(cd "$here/.." && pwd -P) + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export XDG_CACHE_HOME="$tmp/cache" +export HOME="$tmp/home" +mkdir -p "$HOME" +unset BLOCKS_SKILLS_NO_UPDATE BLOCKS_SKILLS_DEBUG BLOCKS_SKILLS_ROOT + +root="$tmp/project" +skill_dir="$root/.agents/skills/blocks-api" +mkdir -p "$root/.agents/skills" +cp -R "$skill_src" "$skill_dir" +script="$skill_dir/scripts/skill-freshness-check.sh" + +# The fake update command records each invocation and acts on a mode file: +# ok print an "up to date" message, exit 0 +# change append to SKILL.md so the fingerprint changes, exit 0 +# fail print the CLI's failure wording, exit 0 (as the real CLI does) +# exit1 exit non-zero without saying anything +# missing print the CLI's "not installed" message, exit 0 +calls="$tmp/calls" +mode="$tmp/mode" +fake="$tmp/fake-update.sh" +cat > "$fake" <> "$calls" +case \$(cat "$mode") in + ok) echo "✓ All global skills are up to date" ;; + change) echo " ✓ Updated blocks-api"; echo "# changed" >> "$skill_dir/SKILL.md" ;; + fail) echo " ✗ Failed to check skills from BlocksOrg/skills" ;; + exit1) exit 1 ;; + missing) echo "No installed skills found matching: blocks-api" ;; +esac +exit 0 +FAKE +chmod +x "$fake" +export BLOCKS_SKILLS_UPDATE_CMD="$fake" + +T0=1700000000 +MIN=60 +HOUR=3600 + +pass=0 +fail=0 +fail_msgs="" + +run() { # run -> sets $out and $rc + out=$(BLOCKS_SKILLS_NOW=$1 bash "$script" 2>"$tmp/stderr") + rc=$? +} + +call_count() { + if [ -f "$calls" ]; then wc -l < "$calls" | tr -d ' '; else echo 0; fi +} + +reset() { + rm -rf "$XDG_CACHE_HOME" "$calls" + echo ok > "$mode" +} + +check() { # check + desc=$1; shift + if "$@"; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + fail_msgs="$fail_msgs + ✗ $desc" + echo "FAIL: $desc" >&2 + fi +} + +# --- first run updates, second run within 24h does not -------------------------- +reset +run $T0 +check "first run exits 0" [ "$rc" -eq 0 ] +check "first run invokes the update command" [ "$(call_count)" -eq 1 ] +check "first run is silent when nothing changed" [ -z "$out" ] +check "update runs from the scope root" [ "$(head -n1 "$calls")" = "$(cd "$root" && pwd -P)" ] + +run $((T0 + 1 * HOUR)) +check "second run within 24h exits 0" [ "$rc" -eq 0 ] +check "second run within 24h skips the update" [ "$(call_count)" -eq 1 ] + +run $((T0 + 23 * HOUR)) +check "run at 23h still skips" [ "$(call_count)" -eq 1 ] + +run $((T0 + 25 * HOUR)) +check "run after 24h updates again" [ "$(call_count)" -eq 2 ] + +# --- a real change prints exactly one note --------------------------------------- +reset +echo change > "$mode" +run $T0 +check "changed files exit 0" [ "$rc" -eq 0 ] +check "changed files print a one-line note" [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" -eq 1 ] +case $out in + *"blocks-api skill was refreshed"*) check "note names the skill" true ;; + *) check "note names the skill" false ;; +esac +echo ok > "$mode" +run $((T0 + 1 * HOUR)) +check "no note on the throttled run that follows" [ -z "$out" ] + +# --- failures retry after 15 minutes, then back off to daily --------------------- +reset +echo fail > "$mode" +run $T0 # failure 1 +check "failure exits 0" [ "$rc" -eq 0 ] +check "failure prints nothing" [ -z "$out" ] +check "failure 1 recorded" [ "$(call_count)" -eq 1 ] + +run $((T0 + 5 * MIN)) +check "no retry 5 minutes after a failure" [ "$(call_count)" -eq 1 ] + +run $((T0 + 16 * MIN)) # failure 2 +check "retry 16 minutes after a failure" [ "$(call_count)" -eq 2 ] + +run $((T0 + 32 * MIN)) # failure 3 +check "third attempt after another 16 minutes" [ "$(call_count)" -eq 3 ] + +run $((T0 + 48 * MIN)) +check "after 3 failures, no retry 16 minutes later" [ "$(call_count)" -eq 3 ] + +run $((T0 + 32 * MIN + 23 * HOUR)) +check "after 3 failures, still no retry at 23h" [ "$(call_count)" -eq 3 ] + +echo ok > "$mode" +run $((T0 + 32 * MIN + 25 * HOUR)) # success resets the counter +check "after 3 failures, retries once a day" [ "$(call_count)" -eq 4 ] + +echo fail > "$mode" +run $((T0 + 32 * MIN + 50 * HOUR)) # failure 1 again +run $((T0 + 32 * MIN + 50 * HOUR + 16 * MIN)) +check "a success resets the failure counter (15m retry again)" [ "$(call_count)" -eq 6 ] + +# --- non-zero exit from the update command counts as a failure ------------------ +reset +echo exit1 > "$mode" +run $T0 +check "non-zero update exit still exits 0" [ "$rc" -eq 0 ] +run $((T0 + 16 * MIN)) +check "non-zero update exit is retried after 15 minutes" [ "$(call_count)" -eq 2 ] + +# --- 'not installed' is not a failure; check again tomorrow, not in 15 minutes ---- +reset +echo missing > "$mode" +run $T0 +check "untracked install exits 0" [ "$rc" -eq 0 ] +check "untracked install prints nothing" [ -z "$out" ] +run $((T0 + 16 * MIN)) +check "untracked install is not retried after 15 minutes" [ "$(call_count)" -eq 1 ] + +# --- opt-out -------------------------------------------------------------------- +reset +BLOCKS_SKILLS_NO_UPDATE=1 run $T0 +check "opt-out exits 0" [ "$rc" -eq 0 ] +check "opt-out prints nothing" [ -z "$out" ] +check "opt-out never invokes the update command" [ "$(call_count)" -eq 0 ] + +# --- a missing update command can never break the caller ------------------------ +reset +BLOCKS_SKILLS_UPDATE_CMD="$tmp/does-not-exist" run $T0 +check "missing update command exits 0" [ "$rc" -eq 0 ] +check "missing update command prints nothing" [ -z "$out" ] + +# --- an unwritable cache dir is skipped quietly --------------------------------- +reset +bad_cache="$tmp/not-a-dir" +touch "$bad_cache" +XDG_CACHE_HOME="$bad_cache" run $T0 +check "unwritable cache exits 0" [ "$rc" -eq 0 ] +check "unwritable cache skips the update" [ "$(call_count)" -eq 0 ] + +# --- debug logging goes to stderr only ------------------------------------------ +reset +out=$(BLOCKS_SKILLS_NOW=$T0 BLOCKS_SKILLS_DEBUG=1 bash "$script" 2>"$tmp/stderr") +check "debug keeps stdout clean" [ -z "$out" ] +check "debug logs the decision to stderr" grep -q 'scope root' "$tmp/stderr" + +echo +echo "$pass passed, $fail failed" +if [ "$fail" -ne 0 ]; then + echo "$fail_msgs" + exit 1 +fi From 2089876c69540ef2f35158e16cbaec9eef8c1d24 Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Mon, 5 Oct 2026 23:55:58 +0000 Subject: [PATCH 2/3] fix(blocks-api): enforce the update deadline without coreutils timeout The freshness check only applied its 120s deadline where `timeout` was on PATH, which excludes stock macOS. Replace it with a bash watchdog: the update runs in its own process group under job control, a sibling subshell kills that group when the deadline passes, and a hit deadline is recorded as a failure so the normal backoff applies. Expose BLOCKS_SKILLS_TIMEOUT for tests and add a hang scenario to the harness that checks exit 0, no output, a bounded wall clock, the whole process tree being killed, and the 15-minute retry. Co-Authored-By: Claude Fable 5.1 --- .../scripts/skill-freshness-check.sh | 54 +++++++++++++++---- .../scripts/skill-freshness-check.test.sh | 21 ++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/skills/blocks-api/scripts/skill-freshness-check.sh b/skills/blocks-api/scripts/skill-freshness-check.sh index e3eb2e9..b4a560b 100755 --- a/skills/blocks-api/scripts/skill-freshness-check.sh +++ b/skills/blocks-api/scripts/skill-freshness-check.sh @@ -17,6 +17,7 @@ # BLOCKS_SKILLS_DEBUG=1 log every decision to stderr # BLOCKS_SKILLS_NOW override the clock (epoch seconds), for tests # BLOCKS_SKILLS_UPDATE_CMD override the update command, for tests +# BLOCKS_SKILLS_TIMEOUT override the update deadline (seconds), for tests # BLOCKS_SKILLS_ROOT override the detected scope root, for tests # XDG_CACHE_HOME marker files live under $XDG_CACHE_HOME/blocks-skills # (defaults to ~/.cache) @@ -27,7 +28,7 @@ SKILLS_CLI_VERSION="1.7.0" SUCCESS_INTERVAL=86400 # 24h: do not re-check after a success RETRY_INTERVAL=900 # 15m: retry after a failure MAX_FAILURES=3 # after this many failures in a row, retry daily -UPDATE_TIMEOUT=120 # seconds; only enforced where `timeout` exists +UPDATE_TIMEOUT=120 # seconds before a stalled update is killed log() { if [ -n "${BLOCKS_SKILLS_DEBUG:-}" ]; then @@ -149,21 +150,56 @@ if [ -z "${BLOCKS_SKILLS_UPDATE_CMD:-}" ] && ! command -v npx >/dev/null 2>&1; t exit 0 fi -runner="" -if command -v timeout >/dev/null 2>&1; then - runner="timeout $UPDATE_TIMEOUT" -fi +deadline=${BLOCKS_SKILLS_TIMEOUT:-$UPDATE_TIMEOUT} +case $deadline in + ''|*[!0-9]*) deadline=$UPDATE_TIMEOUT ;; +esac + +# Run a command with a deadline using only bash builtins, so the guarantee +# holds on systems without coreutils' `timeout` (stock macOS, for one). +# Job control puts the child and the watchdog in their own process groups, +# which lets us take down the whole npx/node tree rather than just the +# wrapping shell. Returns the command's exit code, or 124 on the deadline. +run_with_deadline() { # run_with_deadline + set -m + ( cd "$root" && exec sh -c "$3" ) > "$2" 2>&1 "$2.timeout" + kill -TERM -- "-$child" 2>/dev/null || kill -TERM "$child" 2>/dev/null + sleep 1 + kill -KILL -- "-$child" 2>/dev/null || kill -KILL "$child" 2>/dev/null + ) >/dev/null 2>&1 /dev/null + status=$? + kill -KILL -- "-$watchdog" 2>/dev/null || kill -KILL "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + if [ -f "$2.timeout" ]; then + rm -f "$2.timeout" 2>/dev/null + return 124 + fi + return "$status" +} last_attempt=$now write_marker before=$(fingerprint) -log "running: $update_cmd (in $root)" -# shellcheck disable=SC2086 # $runner is intentionally word-split -output=$(cd "$root" && $runner sh -c "$update_cmd" 2>&1 /dev/null) +rm -f "$output_file" 2>/dev/null after=$(fingerprint) -log "update exit code: $rc" +if [ "$rc" -eq 124 ]; then + log "update killed after ${deadline}s" +else + log "update exit code: $rc" +fi if [ -n "${BLOCKS_SKILLS_DEBUG:-}" ]; then esc=$(printf '\033') printf '%s\n' "$output" | sed "s/$esc\[[0-9;?]*[a-zA-Z]//g; s/^/skill-freshness-check: | /" >&2 diff --git a/skills/blocks-api/scripts/skill-freshness-check.test.sh b/skills/blocks-api/scripts/skill-freshness-check.test.sh index a779e05..91cf10a 100755 --- a/skills/blocks-api/scripts/skill-freshness-check.test.sh +++ b/skills/blocks-api/scripts/skill-freshness-check.test.sh @@ -30,8 +30,10 @@ script="$skill_dir/scripts/skill-freshness-check.sh" # fail print the CLI's failure wording, exit 0 (as the real CLI does) # exit1 exit non-zero without saying anything # missing print the CLI's "not installed" message, exit 0 +# hang spawn a long sleep (pid written to $hung_pid) and wait on it calls="$tmp/calls" mode="$tmp/mode" +hung_pid="$tmp/hung-pid" fake="$tmp/fake-update.sh" cat > "$fake" < "$hung_pid"; wait ;; esac exit 0 FAKE @@ -61,6 +64,8 @@ run() { # run -> sets $out and $rc rc=$? } +not_running() { ! kill -0 "$1" 2>/dev/null; } + call_count() { if [ -f "$calls" ]; then wc -l < "$calls" | tr -d ' '; else echo 0; fi } @@ -163,6 +168,22 @@ check "untracked install prints nothing" [ -z "$out" ] run $((T0 + 16 * MIN)) check "untracked install is not retried after 15 minutes" [ "$(call_count)" -eq 1 ] +# --- a stalled update is killed at the deadline and counted as a failure --------- +reset +echo hang > "$mode" +started=$(date +%s) +BLOCKS_SKILLS_TIMEOUT=2 run $T0 +elapsed=$(( $(date +%s) - started )) +check "stalled update exits 0" [ "$rc" -eq 0 ] +check "stalled update prints nothing" [ -z "$out" ] +check "stalled update returns within the deadline (took ${elapsed}s)" [ "$elapsed" -le 10 ] +check "stalled update's whole process tree is killed" not_running "$(cat "$hung_pid")" +run $((T0 + 5 * MIN)) +check "stalled update counts as a failure (no retry at 5m)" [ "$(call_count)" -eq 1 ] +echo ok > "$mode" +run $((T0 + 16 * MIN)) +check "stalled update is retried after 15 minutes" [ "$(call_count)" -eq 2 ] + # --- opt-out -------------------------------------------------------------------- reset BLOCKS_SKILLS_NO_UPDATE=1 run $T0 From 5171765a4e0fc54998bf53ea91b18aed438482a0 Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Tue, 6 Oct 2026 00:35:05 +0000 Subject: [PATCH 3/3] fix(blocks-api): serialize concurrent freshness checks with a mkdir lock Agent sessions starting within a few milliseconds of each other could all read the marker before any of them wrote it, so each ran its own `skills update` against the same install. Take an atomic mkdir lock before reading the marker and hold it through the update; runs that find it held skip immediately, and a lock older than the deadline plus a minute is taken over. Adds concurrent, held-lock and stale-lock tests (50 checks). Co-Authored-By: Claude Opus 5.5 --- .../scripts/skill-freshness-check.sh | 34 ++++++++++++++++--- .../scripts/skill-freshness-check.test.sh | 30 ++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/skills/blocks-api/scripts/skill-freshness-check.sh b/skills/blocks-api/scripts/skill-freshness-check.sh index b4a560b..5cbad39 100755 --- a/skills/blocks-api/scripts/skill-freshness-check.sh +++ b/skills/blocks-api/scripts/skill-freshness-check.sh @@ -11,6 +11,7 @@ # - after a successful check, stay quiet for 24 hours # - after a failed check, retry 15 minutes later # - after 3 consecutive failures, back off to one attempt per day +# - concurrent runs share a lock; only the first one checks, the rest skip # # Environment: # BLOCKS_SKILLS_NO_UPDATE=1 opt out entirely (exit 0 immediately) @@ -89,6 +90,34 @@ if ! mkdir -p "$marker_dir" 2>/dev/null; then exit 0 fi +deadline=${BLOCKS_SKILLS_TIMEOUT:-$UPDATE_TIMEOUT} +case $deadline in + ''|*[!0-9]*) deadline=$UPDATE_TIMEOUT ;; +esac + +# --- lock --------------------------------------------------------------------- +# Several agent sessions can start at the same moment. `mkdir` is atomic, so +# whoever creates the lock directory reads the marker and runs the update; +# everyone else skips straight away rather than waiting. A lock older than the +# update deadline plus a minute belongs to a run that was killed, so take it +# over. +lock="$marker.lock" +if ! mkdir "$lock" 2>/dev/null; then + stale_after=$(( (deadline + 60 + 59) / 60 )) # minutes, rounded up + if [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_after" 2>/dev/null)" ]; then + log "taking over stale lock $lock" + rmdir "$lock" 2>/dev/null + if ! mkdir "$lock" 2>/dev/null; then + log "lost the race for $lock; skipping" + exit 0 + fi + else + log "another check holds $lock; skipping" + exit 0 + fi +fi +trap 'rmdir "$lock" 2>/dev/null' EXIT + now=${BLOCKS_SKILLS_NOW:-$(date +%s)} case $now in ''|*[!0-9]*) log "invalid clock value '$now'; skipping"; exit 0 ;; @@ -150,11 +179,6 @@ if [ -z "${BLOCKS_SKILLS_UPDATE_CMD:-}" ] && ! command -v npx >/dev/null 2>&1; t exit 0 fi -deadline=${BLOCKS_SKILLS_TIMEOUT:-$UPDATE_TIMEOUT} -case $deadline in - ''|*[!0-9]*) deadline=$UPDATE_TIMEOUT ;; -esac - # Run a command with a deadline using only bash builtins, so the guarantee # holds on systems without coreutils' `timeout` (stock macOS, for one). # Job control puts the child and the watchdog in their own process groups, diff --git a/skills/blocks-api/scripts/skill-freshness-check.test.sh b/skills/blocks-api/scripts/skill-freshness-check.test.sh index 91cf10a..9f15c29 100755 --- a/skills/blocks-api/scripts/skill-freshness-check.test.sh +++ b/skills/blocks-api/scripts/skill-freshness-check.test.sh @@ -31,6 +31,7 @@ script="$skill_dir/scripts/skill-freshness-check.sh" # exit1 exit non-zero without saying anything # missing print the CLI's "not installed" message, exit 0 # hang spawn a long sleep (pid written to $hung_pid) and wait on it +# slow sleep a second, then report "up to date" calls="$tmp/calls" mode="$tmp/mode" hung_pid="$tmp/hung-pid" @@ -45,6 +46,7 @@ case \$(cat "$mode") in exit1) exit 1 ;; missing) echo "No installed skills found matching: blocks-api" ;; hang) sleep 300 & echo \$! > "$hung_pid"; wait ;; + slow) sleep 1; echo "✓ All global skills are up to date" ;; esac exit 0 FAKE @@ -184,6 +186,34 @@ echo ok > "$mode" run $((T0 + 16 * MIN)) check "stalled update is retried after 15 minutes" [ "$(call_count)" -eq 2 ] +# --- concurrent sessions run the update once ------------------------------------ +reset +echo slow > "$mode" +for i in 1 2 3 4 5 6 7 8 9 10; do + BLOCKS_SKILLS_NOW=$T0 bash "$script" > "$tmp/concurrent.$i" 2>&1 & +done +concurrent_rc=0 +wait || concurrent_rc=$? +for i in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$tmp/concurrent.$i" ] && concurrent_rc=1 +done +check "10 concurrent first runs invoke the update once" [ "$(call_count)" -eq 1 ] +check "10 concurrent first runs all exit 0 silently" [ "$concurrent_rc" -eq 0 ] +lock="$XDG_CACHE_HOME/blocks-skills/$(printf '%s' "$(cd "$root" && pwd -P)" | cksum | cut -d' ' -f1)/blocks-api.lock" +check "the lock is released afterwards" [ ! -e "$lock" ] + +# --- a held lock makes the run skip; a stale one is taken over ------------------ +reset +mkdir -p "$lock" +run $T0 +check "held lock exits 0" [ "$rc" -eq 0 ] +check "held lock skips the update" [ "$(call_count)" -eq 0 ] +check "held lock is left for its owner" [ -d "$lock" ] +touch -t 202001010000 "$lock" +run $T0 +check "stale lock is taken over and the update runs" [ "$(call_count)" -eq 1 ] +check "stale lock is released afterwards" [ ! -e "$lock" ] + # --- opt-out -------------------------------------------------------------------- reset BLOCKS_SKILLS_NO_UPDATE=1 run $T0