fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516)

The daily pull refreshed only ~/.gstack itself, never the detached worktree
at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the
brain served stale pages forever unless setup-gbrain/sync-gbrain happened to
run. brain-sync --once now advances the worktree once per 24h behind an
ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance
warns once a day, not at every skill boundary), inside the existing run lock
and before any ingest step touches the worktree.

The new gstack-gbrain-source-wireup --advance-only is built for the
unattended cadence: git-only (no gbrain prereqs), pins every operation to the
managed worktree (refuses paths that are not worktrees of the artifacts
repo), refuses dirty worktrees, and never runs the force-remove recovery — a
cron path must not be able to delete local changes. A static pin keeps the
force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence.

Fixes #2516.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-17 10:32:19 -07:00
co-authored by Claude Fable 5
parent c4d91507dd
commit 40e4a53f74
4 changed files with 238 additions and 3 deletions
+23
View File
@@ -377,6 +377,29 @@ subcmd_once() {
local mode
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
# #2516: advance the brain worktree gbrain indexes to the artifacts repo's
# HEAD once a day — previously it only moved when setup-gbrain / sync-gbrain
# / brain-restore ran, so brains silently served stale code forever. Runs
# inside THIS run lock (never concurrent with the ingest steps below) and
# before they touch the worktree. Attempt-throttled: the stamp is written on
# ATTEMPT, so a persistently-failing advance warns once per 24h, not at
# every skill boundary. The advance itself refuses dirty or unmanaged
# worktrees and never force-removes (see gstack-gbrain-source-wireup).
if [ -e "${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" ]; then
local adv_stamp adv_now adv_last adv_age
adv_stamp="$GSTACK_HOME/.brain-worktree-last-advance"
adv_now=$(date +%s)
adv_last=$(cat "$adv_stamp" 2>/dev/null || echo 0)
case "$adv_last" in ''|*[!0-9]*) adv_last=0 ;; esac
adv_age=$(( adv_now - adv_last ))
if [ "$adv_age" -ge 86400 ]; then
echo "$adv_now" > "$adv_stamp" 2>/dev/null || true
if ! "$SCRIPT_DIR/gstack-gbrain-source-wireup" --advance-only 1>&2; then
echo "BRAIN_SYNC: warning: brain worktree advance failed — gbrain may be indexing stale code (run gstack-gbrain-source-wireup to repair)" >&2
fi
fi
fi
# #2549 unpushed-commit detector: a prior drain may have COMMITTED but
# failed to push (auth blip, offline). The data was never lost — it sits in
# a local commit — but nothing re-pushed it until NEW changes arrived.
+50 -3
View File
@@ -12,6 +12,7 @@
# gstack-gbrain-source-wireup --uninstall [--source-id <id>]
# [--database-url <url>]
# gstack-gbrain-source-wireup --probe
# gstack-gbrain-source-wireup --advance-only # daily unattended worktree advance (#2516)
# gstack-gbrain-source-wireup --help
#
# Exit codes:
@@ -64,6 +65,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--uninstall) MODE="uninstall"; shift ;;
--probe) MODE="probe"; shift ;;
--advance-only) MODE="advance-only"; shift ;;
--strict) STRICT=1; shift ;;
--no-pull) NO_PULL=1; shift ;;
--source-id) SOURCE_ID="$2"; shift 2 ;;
@@ -336,6 +338,50 @@ do_wireup() {
echo "pages_synced=$(echo "$sync_out" | grep -oE '[0-9]+ pages? imported' | head -1 || echo 'incremental')"
}
do_advance_only() {
# Daily unattended advance (#2516): the brain worktree gbrain indexes only
# moved when setup-gbrain / sync-gbrain / brain-restore ran, so brains
# silently served stale code. This mode is git-only (no gbrain prereqs) and
# SAFE for a cron cadence: it refuses dirty worktrees and NEVER runs
# ensure_worktree's force-remove recovery — an unattended path must not be
# able to delete local worktree changes. All git ops are pinned to
# $GSTACK_HOME / $WORKTREE, never cwd-derived.
[ -d "$GSTACK_HOME/.git" ] || { warn "advance-only: no artifacts repo at $GSTACK_HOME; nothing to advance"; exit 0; }
if [ ! -d "$WORKTREE/.git" ] && [ ! -f "$WORKTREE/.git" ]; then
warn "advance-only: no managed worktree at $WORKTREE (run the setup-gbrain wireup first)"
exit 0
fi
# Managed-marker check: refuse anything that is not a worktree OF the
# artifacts repo — a misconfigured GSTACK_BRAIN_WORKTREE pointing at a user
# repo must never be advanced/detached.
local gitdir home_git
gitdir=$(git -C "$WORKTREE" rev-parse --absolute-git-dir 2>/dev/null || echo "")
# Physical path for the comparison: rev-parse returns resolved paths, while
# $GSTACK_HOME may reach the same place through a symlink (macOS /var/folders).
home_git=$(cd "$GSTACK_HOME/.git" 2>/dev/null && pwd -P || echo "$GSTACK_HOME/.git")
case "$gitdir" in
"$home_git/worktrees/"*) : ;;
*) warn "advance-only: $WORKTREE is not a worktree of $GSTACK_HOME (gitdir: ${gitdir:-unreadable}); refusing"; exit 0 ;;
esac
if [ -n "$(git -C "$WORKTREE" status --porcelain 2>/dev/null)" ]; then
warn "advance-only: worktree at $WORKTREE has local changes; refusing to advance them away"
exit 0
fi
local sha cur
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
echo "advance-only: up-to-date at $sha"
return 0
fi
if ( cd "$WORKTREE" && git checkout --detach "$sha" 2>&1 | prefix; exit "${PIPESTATUS[0]}" ); then
echo "advance-only: advanced $WORKTREE to $sha"
else
warn "advance-only: could not advance $WORKTREE to $sha; NOT force-resetting on the unattended path. Run gstack-gbrain-source-wireup to repair."
exit 1
fi
}
do_uninstall() {
local id
id=$(derive_source_id) || die "cannot derive source id; pass --source-id <id> explicitly" 3
@@ -356,7 +402,8 @@ do_uninstall() {
}
case "$MODE" in
probe) do_probe ;;
wireup) do_wireup ;;
uninstall) do_uninstall ;;
probe) do_probe ;;
wireup) do_wireup ;;
uninstall) do_uninstall ;;
advance-only) do_advance_only ;;
esac