mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +02:00
fix(brain-sync): detector pushes only when ALL unpushed commits are its own; lock released on every exit
The unpushed-commit detector's author check was existential: any bot-authored commit in origin/<branch>..HEAD armed a push of HEAD, silently publishing interleaved user-authored commits in ~/.gstack. Now the gate requires the author-scoped count to equal the total unpushed count — one user commit disables the autonomous retry entirely (user commits still ride along when a real drain pushes). Detached HEAD is excluded (origin/HEAD usually resolves, making the retry a 10-minutely doomed push). The lock-release trap now installs immediately after lock acquisition instead of after the empty-queue fast path — the steady state at every skill boundary leaked the lock dir and relied on stale-PID detection, which PID reuse defeats. An INT during the detector's network push is covered too. Matrix test: interleaved user commit blocks the detector, then a real drain delivers everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
dcc6e9e323
commit
6bcd2ddfa4
+22
-5
@@ -366,6 +366,13 @@ subcmd_once() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo "$$" > "$lock_dir/pid" 2>/dev/null || true
|
echo "$$" > "$lock_dir/pid" 2>/dev/null || true
|
||||||
|
# Release the lock on EVERY exit from here on — including the empty-queue
|
||||||
|
# fast path and an INT during the detector's network push. Leaking it would
|
||||||
|
# rely on next-run stale-pid detection, which PID reuse can defeat (kill -0
|
||||||
|
# matching an unrelated live process wedges sync at every boundary). The
|
||||||
|
# mktemp block below re-traps with tempfile cleanup added; both traps keep
|
||||||
|
# the lock removal.
|
||||||
|
trap 'rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
local mode
|
local mode
|
||||||
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
|
||||||
@@ -387,18 +394,27 @@ subcmd_once() {
|
|||||||
# bounds stalled transfers via git's own low-speed limits (portable — stock
|
# bounds stalled transfers via git's own low-speed limits (portable — stock
|
||||||
# macOS ships no `timeout` binary).
|
# macOS ships no `timeout` binary).
|
||||||
#
|
#
|
||||||
# Author-scoped: only commits authored by gstack-brain-sync retry here. A
|
# Author-scoped — EXCLUSIVELY: `git push origin HEAD` publishes every
|
||||||
# user's manual commit in ~/.gstack rides along when a real drain pushes,
|
# unpushed commit, so the retry fires only when ALL unpushed commits are
|
||||||
# as before — the detector must not auto-publish work it didn't create.
|
# gstack-brain-sync's own. One interleaved user commit disables the
|
||||||
local det_branch det_unpushed det_now det_last
|
# auto-retry entirely (adversarial review: an existential check would
|
||||||
|
# silently auto-publish a user's manual ~/.gstack commit the moment a bot
|
||||||
|
# commit sat in front of it). User commits ride along when a REAL drain
|
||||||
|
# pushes, as before — the detector never publishes work it didn't create.
|
||||||
|
local det_branch det_unpushed det_total det_now det_last
|
||||||
det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||||
|
# Detached HEAD reads as the literal "HEAD" — origin/HEAD usually resolves,
|
||||||
|
# so without this exclusion the detector would retry a doomed push forever.
|
||||||
|
[ "$det_branch" = "HEAD" ] && det_branch=""
|
||||||
if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then
|
if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then
|
||||||
det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
|
det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
|
||||||
|
det_total=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
|
||||||
case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac
|
case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac
|
||||||
|
case "$det_total" in ''|*[!0-9]*) det_total=0 ;; esac
|
||||||
det_now=$(date +%s)
|
det_now=$(date +%s)
|
||||||
det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0)
|
det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0)
|
||||||
case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac
|
case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac
|
||||||
if [ "$det_unpushed" -gt 0 ] && [ $(( det_now - det_last )) -ge 600 ]; then
|
if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then
|
||||||
echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true
|
echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true
|
||||||
local det_host
|
local det_host
|
||||||
det_host=$(remote_host)
|
det_host=$(remote_host)
|
||||||
@@ -414,6 +430,7 @@ subcmd_once() {
|
|||||||
# nothing to classify, retain, or drop, and a concurrent append after this
|
# nothing to classify, retain, or drop, and a concurrent append after this
|
||||||
# check simply waits for the next boundary. (The detector above already ran:
|
# check simply waits for the next boundary. (The detector above already ran:
|
||||||
# its whole point is re-pushing stranded commits when the queue is empty.)
|
# its whole point is re-pushing stranded commits when the queue is empty.)
|
||||||
|
# The lock-release trap installed at acquisition covers this exit.
|
||||||
if [ ! -s "$QUEUE" ]; then
|
if [ ! -s "$QUEUE" ]; then
|
||||||
write_status "idle" "queue empty"
|
write_status "idle" "queue empty"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -644,4 +644,39 @@ describe('#2549 queue integrity', () => {
|
|||||||
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an interleaved user commit disables the detector push (exclusive author gate)', () => {
|
||||||
|
initWithMode('full');
|
||||||
|
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"a","ts":"2026-01-01T00:00:00Z"}\n');
|
||||||
|
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
|
||||||
|
// Strand a bot commit behind a rejecting remote.
|
||||||
|
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
|
||||||
|
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n');
|
||||||
|
fs.chmodSync(hook, 0o755);
|
||||||
|
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"b","ts":"2026-01-02T00:00:00Z"}\n');
|
||||||
|
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
fs.rmSync(hook);
|
||||||
|
|
||||||
|
// A user manually commits in ~/.gstack on top of the stranded bot commit.
|
||||||
|
expect(git(['-c', 'user.name=Garry', '-c', 'user.email=garry@example.com',
|
||||||
|
'-c', 'commit.gpgsign=false',
|
||||||
|
'commit', '--allow-empty', '-m', 'manual note']).status).toBe(0);
|
||||||
|
|
||||||
|
// Interval passed, remote healthy, queue empty: the detector must STILL
|
||||||
|
// refuse — `push origin HEAD` would publish the user's commit uninvited.
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.brain-last-push-attempt'), '0');
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
expect(Number(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim())).toBe(2);
|
||||||
|
|
||||||
|
// A REAL drain still rides the user commit along, as before — the gate
|
||||||
|
// scopes only the detector's autonomous retry, not user-initiated syncs.
|
||||||
|
fs.appendFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"c","ts":"2026-01-03T00:00:00Z"}\n');
|
||||||
|
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
expect(git(['rev-list', '--count', 'origin/main..HEAD']).stdout.trim()).toBe('0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user