fix(brain-sync): one drain lock for every writer to the artifacts repo

gstack-artifacts-init, gstack-brain-restore, gstack-gbrain-source-wireup
and the skill-start merge ran git in $GSTACK_HOME without the drain's lock,
so the drain could never tell an abandoned .git/index.lock from a live one.
They now take the same mkdir lock (bin/gstack-brain-lock.sh): the drain and
the skill-start merge skip when it is held, user-run writers wait for it
(GSTACK_BRAIN_LOCK_WAIT, default 120 s), and a child of the holder (the
drain's daily worktree advance, restore's wireup) inherits it by token.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-07 20:09:42 +00:00
1 parent 7fab2a7139
commit 988fe8fe76
5 files changed
+133 -3

No files matched your search

+12 -1
View File
@@ -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'
+89
View File
@@ -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 <gstack_home> <try|wait>
# 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
}
+10 -1
View File
@@ -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%%[/:]*}"
+15
View File
@@ -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
+7 -1
View File
@@ -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"