diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index a82f9e7a4..5963649aa 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -260,6 +260,17 @@ EOF exit 1 fi +# ---- drain lock ---- +# Every gstack writer to the artifacts repo's git state takes the same lock, +# so a drain never meets this init's git index mid-write (and can treat an +# old index lock as abandoned). +. "$SCRIPT_DIR/gstack-brain-lock.sh" +if ! gstack_brain_lock_acquire "$GSTACK_HOME" wait; then + echo "gstack-artifacts-init: another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" >&2 + exit 1 +fi +trap 'gstack_brain_lock_release' EXIT + # ---- git init ---- if [ ! -d "$GSTACK_HOME/.git" ]; then git -C "$GSTACK_HOME" init -q -b main 2>/dev/null || git -C "$GSTACK_HOME" init -q @@ -282,7 +293,7 @@ EOF ALLOWLIST="$GSTACK_HOME/.brain-allowlist" ALLOWLIST_TMP=$(mktemp "$GSTACK_HOME/.brain-allowlist.XXXXXX") ALLOWLIST_ASSEMBLED=$(mktemp "$GSTACK_HOME/.brain-allowlist.XXXXXX") -trap 'rm -f "$ALLOWLIST_TMP" "$ALLOWLIST_ASSEMBLED"' EXIT +trap 'rm -f "$ALLOWLIST_TMP" "$ALLOWLIST_ASSEMBLED"; gstack_brain_lock_release' EXIT ALLOWLIST_MARKER='# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)' cat > "$ALLOWLIST_TMP" <<'EOF' diff --git a/bin/gstack-brain-lock.sh b/bin/gstack-brain-lock.sh new file mode 100644 index 000000000..2f60ac942 --- /dev/null +++ b/bin/gstack-brain-lock.sh @@ -0,0 +1,89 @@ +# gstack-brain-lock.sh — the one drain lock for every gstack writer to the +# artifacts repo's git state in $GSTACK_HOME. +# +# This file is NOT executable; source it: +# +# . "$(dirname "$0")/gstack-brain-lock.sh" +# +# Writers that take it: the drain (gstack-brain-sync --once), the skill-start +# merge (gstack-skill-start), gstack-artifacts-init, gstack-brain-restore and +# gstack-gbrain-source-wireup. Holding it is what lets the drain treat an old +# .git/index.lock as abandoned: no other gstack writer can be running git there. +# +# Provides: +# gstack_brain_lock_acquire +# Returns 0 when this process holds the lock afterwards, 1 otherwise. +# try — give up at once when a live process holds it. +# wait — retry once a second for GSTACK_BRAIN_LOCK_WAIT seconds (default +# 120), then give up. +# A child of the holder (the drain runs the wireup's daily advance; restore +# runs the wireup) inherits GSTACK_BRAIN_LOCK_TOKEN; when it matches the +# lock's owner file the child proceeds without taking or releasing it. +# On failure, _GSTACK_BRAIN_LOCK_HOLDER names the holder's pid (or ""). +# gstack_brain_lock_release +# Removes the lock only when this process took it and still owns it. +# +# The lock is a directory (mkdir is atomic on every POSIX filesystem; flock(1) +# is not on macOS) holding `pid` and `owner`. A lock whose pid is dead is +# stale and is cleared. A lock directory with no pid file is a holder killed +# between mkdir and the pid write once it is older than 10 minutes. + +_GSTACK_BRAIN_LOCK_DIR="" +_GSTACK_BRAIN_LOCK_MINE=0 +_GSTACK_BRAIN_LOCK_HOLDER="" + +_gstack_brain_lock_try_once() { + local dir="$1" pid + if mkdir "$dir" 2>/dev/null; then + GSTACK_BRAIN_LOCK_TOKEN="$$-${RANDOM:-0}-$(date +%s 2>/dev/null || echo 0)" + export GSTACK_BRAIN_LOCK_TOKEN + printf '%s\n' "$$" > "$dir/pid" 2>/dev/null || true + printf '%s\n' "$GSTACK_BRAIN_LOCK_TOKEN" > "$dir/owner" 2>/dev/null || true + _GSTACK_BRAIN_LOCK_MINE=1 + return 0 + fi + if [ -f "$dir/pid" ]; then + pid=$(head -1 "$dir/pid" 2>/dev/null || echo "") + _GSTACK_BRAIN_LOCK_HOLDER="$pid" + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$pid" 2>/dev/null && return 1 + elif [ -z "$(find "$dir" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then + _GSTACK_BRAIN_LOCK_HOLDER="" + return 1 + fi + rm -rf "$dir" 2>/dev/null || true + return 1 +} + +gstack_brain_lock_acquire() { + local home="$1" mode="${2:-try}" waited=0 limit + _GSTACK_BRAIN_LOCK_DIR="$home/.brain-sync.lock.d" + _GSTACK_BRAIN_LOCK_MINE=0 + _GSTACK_BRAIN_LOCK_HOLDER="" + if [ -n "${GSTACK_BRAIN_LOCK_TOKEN:-}" ] && [ -f "$_GSTACK_BRAIN_LOCK_DIR/owner" ] \ + && [ "$(head -1 "$_GSTACK_BRAIN_LOCK_DIR/owner" 2>/dev/null)" = "$GSTACK_BRAIN_LOCK_TOKEN" ]; then + return 0 + fi + mkdir -p "$home" 2>/dev/null || true + limit="${GSTACK_BRAIN_LOCK_WAIT:-120}" + case "$limit" in ''|*[!0-9]*) limit=120 ;; esac + while :; do + _gstack_brain_lock_try_once "$_GSTACK_BRAIN_LOCK_DIR" && return 0 + # A stale lock was just cleared: take it now instead of after a sleep. + [ -d "$_GSTACK_BRAIN_LOCK_DIR" ] || { _gstack_brain_lock_try_once "$_GSTACK_BRAIN_LOCK_DIR" && return 0; } + [ "$mode" = "wait" ] || return 1 + [ "$waited" -ge "$limit" ] && return 1 + sleep 1 + waited=$(( waited + 1 )) + done +} + +gstack_brain_lock_release() { + [ "$_GSTACK_BRAIN_LOCK_MINE" = "1" ] || return 0 + if [ "$(head -1 "$_GSTACK_BRAIN_LOCK_DIR/owner" 2>/dev/null)" = "${GSTACK_BRAIN_LOCK_TOKEN:-}" ]; then + rm -rf "$_GSTACK_BRAIN_LOCK_DIR" 2>/dev/null || true + fi + _GSTACK_BRAIN_LOCK_MINE=0 + unset GSTACK_BRAIN_LOCK_TOKEN + return 0 +} diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index f81a14d13..4438c0a72 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -89,9 +89,18 @@ EOF fi fi +# ---- drain lock ---- +# Every gstack writer to the artifacts repo's git state takes the same lock; +# the wireup this script runs at the end inherits it. +. "$SCRIPT_DIR/gstack-brain-lock.sh" +if ! gstack_brain_lock_acquire "$GSTACK_HOME" wait; then + echo "gstack-brain-restore: another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" >&2 + exit 1 +fi + # ---- clone to staging ---- STAGING=$(mktemp -d "${TMPDIR:-/tmp}/gstack-brain-restore.XXXXXX") -trap 'rm -rf "$STAGING" 2>/dev/null' EXIT +trap 'rm -rf "$STAGING" 2>/dev/null; gstack_brain_lock_release' EXIT echo "Cloning $REMOTE_URL to staging..." RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}" diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 42fcea288..11248bf28 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -56,6 +56,17 @@ fi PLIST_PATH="$HOME/Library/LaunchAgents/com.gstack.brain-sync.plist" GBRAIN_CONFIG="$HOME/.gbrain/config.json" +# Git writes to $GSTACK_HOME (worktree add/prune/remove, the daily advance) +# take the drain lock every gstack writer shares. A caller that already holds +# it (the drain's daily advance, gstack-brain-restore) passes it down. +. "$SCRIPT_DIR/gstack-brain-lock.sh" +trap 'gstack_brain_lock_release' EXIT +take_drain_lock() { + [ -d "$GSTACK_HOME/.git" ] || return 0 + gstack_brain_lock_acquire "$GSTACK_HOME" wait \ + || die "another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" 1 +} + # ---- arg parse ---- MODE="wireup" STRICT=0 @@ -285,10 +296,12 @@ do_wireup() { # Capture ensure_worktree's return code explicitly. `$?` after `||` reflects # the LAST command in the function under set -e, which is unreliable when the # function has multiple internal exit paths. + take_drain_lock set +e ensure_worktree ew_rc=$? set -e + gstack_brain_lock_release case "$ew_rc" in 0) : ;; # success 2) @@ -403,6 +416,7 @@ do_advance_only() { exit 0 fi local sha cur + take_drain_lock sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || { warn "advance-only: cannot read parent HEAD"; exit 0; } cur=$(git -C "$WORKTREE" rev-parse HEAD 2>/dev/null || echo "") if [ "$cur" = "$sha" ]; then @@ -426,6 +440,7 @@ do_uninstall() { fi if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then + take_drain_lock git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null \ || safe_rm_worktree "$WORKTREE" fi diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 9e4417943..6a922158f 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -320,12 +320,18 @@ if [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then # fail-closed (same wiring as bin/gstack-brain-sync's fetch/push). . "$_BIN/gstack-egress-lib.sh" 2>/dev/null || true _PULL_HOST=$(cd "$_GH" 2>/dev/null && git remote get-url origin 2>/dev/null | sed -E 's|^[a-z+]+://([^/@]*@)?([^/:]+).*|\2|; s|^([^@]+@)?([^:]+):.*|\2|' | head -1) - if command -v _receipted_git >/dev/null 2>&1; then + # The merge writes the artifacts repo's git state, so it takes the drain + # lock every gstack writer shares; when a drain holds it, the pull waits + # for the next start (no stamp). + . "$_BIN/gstack-brain-lock.sh" 2>/dev/null || true + if command -v _receipted_git >/dev/null 2>&1 && command -v gstack_brain_lock_acquire >/dev/null 2>&1 \ + && gstack_brain_lock_acquire "$_GH" try; then # Non-interactive + slow-network bounded (same guards as gstack-update-check): # a hung remote or a credential prompt must not stall the preamble. ( cd "$_GH" && GSTACK_HOME="$_GH" GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \ _receipted_git closed brain-sync "${_PULL_HOST:-unknown}" curated-memory-git-fetch "artifacts_sync_mode!=off" \ git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true + gstack_brain_lock_release # Stamp only when the receipted path actually ran — a missing egress lib # must surface as a retry next start, not a silent 24h suppression. echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE"