Repository navigation
feat(blocks-api): add throttled self-update freshness check #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
94d50ae
feat(blocks-api): add throttled self-update freshness check
AlejandroEsquivel 2089876
fix(blocks-api): enforce the update deadline without coreutils timeout
AlejandroEsquivel 5171765
fix(blocks-api): serialize concurrent freshness checks with a mkdir lock
AlejandroEsquivel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| #!/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 | ||
| # - 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) | ||
| # 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) | ||
|
|
||
| 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 before a stalled update is killed | ||
|
|
||
| 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/<name> | ||
| # still lands on the real ~/.agents/skills/<name> 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 | ||
|
|
||
| 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 ;; | ||
| 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 | ||
|
|
||
| # 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 <seconds> <output-file> <command> | ||
| set -m | ||
| ( cd "$root" && exec sh -c "$3" ) > "$2" 2>&1 </dev/null & | ||
| child=$! | ||
| ( | ||
| sleep "$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 & | ||
| watchdog=$! | ||
| set +m | ||
| wait "$child" 2>/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, deadline ${deadline}s)" | ||
| output_file="$marker.$$.out" | ||
| run_with_deadline "$deadline" "$output_file" "$update_cmd" | ||
| rc=$? | ||
| output=$(cat "$output_file" 2>/dev/null) | ||
| rm -f "$output_file" 2>/dev/null | ||
| after=$(fingerprint) | ||
| 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 | ||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.