fix(kyberforge): make the apm currency hook portable and bounded
Why: stock macOS has no timeout(1), so the hook exited silently and never checked the install, the silent staleness ADR-0019 exists to prevent. - fall back to gtimeout, else emit a notice instead of running apm unbounded - kill after a 5s grace; worst case 370s stays under the 380s host limit - export GIT_TERMINAL_PROMPT=0 so a credential prompt cannot hang startup - serialise concurrent refreshes with flock when available Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
This commit is contained in:
@@ -38,27 +38,47 @@ command -v apm > /dev/null 2>&1 || exit 0
|
||||
# `apm outdated` and `apm update` both resolve the lockfile from the cwd.
|
||||
cd "$project_dir" || exit 0
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
# command output into the JSON, which would need escaping this cannot do safely.
|
||||
emit() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2"
|
||||
}
|
||||
|
||||
# Every apm call is time-boxed, so timeout(1) is a hard requirement. It is GNU
|
||||
# coreutils: stock macOS has none, and Homebrew's coreutils installs it as
|
||||
# `gtimeout`. Calling a missing binary would exit 127, which the `|| exit 0`
|
||||
# below swallows — the hook would silently never work. Say so once instead, and
|
||||
# do not run apm unbounded.
|
||||
if command -v timeout > /dev/null 2>&1; then
|
||||
timeout_bin="timeout"
|
||||
elif command -v gtimeout > /dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
emit false "The apm install currency check did not run: neither timeout nor gtimeout (GNU coreutils) is on PATH, and this hook will not run apm without a time limit. Install coreutils (macOS: brew install coreutils) or run apm outdated by hand."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# apm drives git for every remote ref. A remote that wants credentials must fail
|
||||
# fast, not block on a terminal prompt nobody can see until the timeout fires.
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
# `apm outdated` exits 0 whether or not anything is stale, so the answer has to
|
||||
# come from its output. ~0.7s against six remote refs; a hung remote must not
|
||||
# hold the session open.
|
||||
# hold the session open. -k sends SIGKILL a grace period after the SIGTERM, so a
|
||||
# child that ignores TERM cannot outlive its budget; tests/test-apm-current-hook.sh
|
||||
# sums every limit plus its grace against the hooks.json timeout.
|
||||
#
|
||||
# There is no --json/machine-readable flag on `apm outdated` (verified against
|
||||
# apm 0.28.0), so the phrase match is forced rather than chosen. Note the
|
||||
# singular: apm prints "1 outdated dependency found" when exactly one package is
|
||||
# behind, so matching only "dependencies" would silently miss a one-package
|
||||
# drift. tests/test-apm-current-hook.sh pins both spellings against the real apm.
|
||||
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
|
||||
outdated_output="$("$timeout_bin" -k 5 60 apm outdated 2>&1)" || exit 0
|
||||
grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0
|
||||
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependenc(y|ies) found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
[[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some"
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
# command output into the JSON, which would need escaping this cannot do safely.
|
||||
emit() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2"
|
||||
}
|
||||
|
||||
# What to do with the rewritten lock depends on the branch (ADR-0019): on the
|
||||
# default branch it is a real update to commit or discard; on a feature branch it
|
||||
# is churn unrelated to the branch and should be discarded. The branch name only
|
||||
@@ -85,7 +105,23 @@ if [[ -n "$current_branch" && -n "$default_branch" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if timeout 300 apm update --yes > /dev/null 2>&1; then
|
||||
# Two sessions started together would both run `apm update --yes` over the same
|
||||
# tree. Serialise on a lock under apm_modules/: `apm install` itself adds that
|
||||
# directory to .gitignore, so the lock never shows up as a working-tree change,
|
||||
# and apm only ever removes package directories inside it, never the directory
|
||||
# (or this file) itself. The loser does not wait — the winner's refresh is the
|
||||
# one it wanted — and says so. flock(1) is util-linux, absent on stock macOS,
|
||||
# and there the refresh runs unserialised, as it did before the lock existed; so
|
||||
# does a checkout with no apm_modules/ yet, rather than creating it.
|
||||
if command -v flock > /dev/null 2>&1 && [[ -d apm_modules ]] \
|
||||
&& { exec 9> apm_modules/.kyberforge-apm-update.lock; } 2> /dev/null; then
|
||||
if ! flock -n 9; then
|
||||
emit false "apm install is ${stale_count} package(s) behind the remote default branch, and another session is refreshing it right now, so this session skipped its own refresh. Skills and agents loaded in this session may be stale; if they are, restart the session once that refresh has finished."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$timeout_bin" -k 5 300 apm update --yes > /dev/null 2>&1; then
|
||||
emit true "apm install was ${stale_count} package(s) behind the remote default branch and has been refreshed automatically; skills and agents were redeployed and re-scanned. apm.lock.yaml has been rewritten and is now a modified file in the working tree - ${lock_advice}"
|
||||
else
|
||||
emit false "apm install is ${stale_count} package(s) behind the remote default branch and the automatic refresh failed. Deployed skills and agents may be stale. Run: apm update --yes"
|
||||
|
||||
Reference in New Issue
Block a user