Merge remote-tracking branch 'origin/main' into capy/fw3-b

This commit is contained in:
garrytan committed 2026-10-17 22:02:49 +00:00
commit 49b57a9be4
14 files changed
+590 -20

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [1.91.126.0] - 2026-10-17
**Artifacts sync stops saying "ok" when two project folders differ only by letter case.** When the artifacts repo tracks two spellings of one project, such as `projects/MyApp/` and `projects/myapp/`, a Mac or Windows disk stores them as one file. Git there staged each write under the other spelling and the sync reported `ok`. One spelling then stayed modified forever, and other machines read the wrong bucket (#3220). This lands the detector from Kkkkkuro's #3223 and #3245, with credit, and holds those files instead of halting the whole sync.
### Fixed
- **Case-colliding paths are held, not staged.** A queued file whose path differs only by letter case (NFC plus casefold, at any folder level) from another path in the repo or the batch stays queued and is never staged. Everything else keeps syncing, and the status is `collision`, never `ok`. When one spelling is already tracked, it keeps syncing and only the new alias waits.
- **Sync verifies what `git add` staged and what a push would publish** (Kkkkkuro, #3245). Paths or bytes that never reached the index keep the queue. A local commit or retry merge that would add a new case alias to the remote stays local, and that check now covers `--publish-removals` too.
### Added
- **One skill-start line names the collision and the repair:** `ARTIFACTS_SYNC: attention: case-collision held=<N> names=<names>. ...`, with the manual repair at `docs/troubleshooting.md#artifact-case-collision`. `gstack-doctor` warns the same way. Slug resolution and skill start are unchanged and never stop over this.
### If you…
- **…see the case-collision line:** keep one spelling with the repair steps in the troubleshooting doc. Nothing is merged or renamed automatically.
## [1.91.125.0] - 2026-10-17
**/cso release qualification measures precision on the application, not on the evaluator's own test rigging.** In the first paid runtime qualification, the daily precision gate failed on every stack (8/34 to 8/49). 116 of the 132 findings counted as false positives pointed at instrumentation the evaluator plants in each fixture: canary credentials and the egress probe route. Most of the other 16 were real issues the fixtures happen to contain. Garry chose to keep the 0.95 bar and fix what it counts.
+1 -1
View File
@@ -1 +1 @@
1.91.125.0
1.91.126.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.125.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.126.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+96 -11
View File
@@ -56,6 +56,9 @@
# stale-push trigger: no push for 24 hours while the drain's drainable count is above zero
# line: ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: <bin> --status
# fix: the waiting reason <bin> --status names (a coupled group waiting for a consistent generation, a git add failure)
# case-collision trigger: case_names is set: artifact paths differ only by letter case, so the drain holds queued files under them
# line: ARTIFACTS_SYNC: attention: case-collision held=<N> names=<names>. Artifact paths differ only by letter case, which a Mac or Windows disk stores as one file; sync holds <N> queued file(s) under them and syncs everything else. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision. See the paths: <bin> --status
# fix: keep one spelling: the manual repair in docs/troubleshooting.md#artifact-case-collision
# stale-drain trigger: last_drain_at older than 24 hours while the queue directory holds records
# line: ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: <bin> --status
# fix: if no gstack process is running, remove ~/.gstack/.brain-sync.lock.d, then run <bin> --once
@@ -115,6 +118,8 @@ REMOVAL_VALVE=20
ST_HELD='[]'
ST_DRAINABLE=0
ST_NOTE=""
ST_CASE_HELD=0
ST_CASE_NAMES=""
# origin host for receipt records (github.com etc).
remote_host() {
@@ -136,8 +141,13 @@ remote_auth_hint() {
}
# Status file schema (one JSON object; readers: --status, --attention, doctor):
# status ok | idle | held | blocked | push_failed | error
# status ok | idle | held | collision | blocked | push_failed | error
# (collision: files were held only for case-colliding paths)
# held_count files the secret scan held back this drain (hits + dependents)
# case_held queued files held because their path differs only by letter
# case from another path in the repo or the batch
# case_names the colliding path component names, comma-separated and
# clamped to [A-Za-z0-9._?-]; empty when the tree has none
# drainable queued files a drain could publish now: excludes scanner-held
# and privacy-held records
# last_drain_at UTC time this drain ran (every write happens inside a drain)
@@ -152,9 +162,9 @@ write_status() {
local code="$1" msg="$2${ST_NOTE}" ts last_push=""
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
[ -f "$LAST_PUSH_FILE" ] && last_push=$(head -1 "$LAST_PUSH_FILE" 2>/dev/null || echo "")
python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$last_push" "$ST_DRAINABLE" "$ST_HELD" <<'PYEOF' 2>/dev/null || true
python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$last_push" "$ST_DRAINABLE" "$ST_HELD" "$ST_CASE_HELD" "$ST_CASE_NAMES" <<'PYEOF' 2>/dev/null || true
import json, re, sys
path, code, msg, ts, last_push, drainable, held = sys.argv[1:8]
path, code, msg, ts, last_push, drainable, held, case_held, case_names = sys.argv[1:10]
try:
held = [h for h in json.loads(held) if isinstance(h, dict)]
except Exception:
@@ -171,6 +181,8 @@ data = {
"last_drain_at": ts,
"last_push_at": last_push if re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", last_push) else None,
"ts": ts,
"case_held": int(case_held) if case_held.isdigit() else 0,
"case_names": case_names if re.fullmatch(r"[A-Za-z0-9._?,-]{0,300}", case_names) else "",
"held": held,
"message": msg,
}
@@ -767,6 +779,51 @@ stage_paths() {
rm -f "$paths_file.lf"
}
# Check outgoing trees too: a retry merge can combine two individually safe
# spellings, and a retained local commit can be pushed with an empty queue.
# Also records the tree's case-colliding names for skill start. Without
# python3 no drain can stage anything, so there is no new tree to check. A
# passing verdict is cached by HEAD's target and the commit ids of every ref
# (origin's included, the comparison baseline), so the empty-queue drain at
# every skill boundary skips the tree walk until one of them moves.
validate_outgoing_paths() {
local out key cached cache="$GSTACK_HOME/.brain-case-check"
command -v python3 >/dev/null 2>&1 || return 0
key=$({ head -1 "$GSTACK_HOME/.git/HEAD"; git -C "$GSTACK_HOME" show-ref --head; } 2>/dev/null | cksum)
cached=$(head -1 "$cache" 2>/dev/null || true)
if [ "${cached%% *}" = "$key" ]; then
[ -n "$ST_CASE_NAMES" ] || ST_CASE_NAMES=${cached#* }
return 0
fi
if ! out=$(python3 "$SCRIPT_DIR/gstack-brain-sync-index.py" --outgoing "$GSTACK_HOME"); then
ST_CASE_NAMES=${out%$'\r'}
write_status "error" "outgoing artifact path validation refused: a local commit adds a path that differs only by letter case from a path on the remote; local commits and queue preserved. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision"
return 1
fi
out=${out%$'\r'}
printf '%s\t%s\n' "$key" "$out" > "$cache" 2>/dev/null || true
[ -n "$ST_CASE_NAMES" ] || ST_CASE_NAMES=$out
}
# Hold the queued paths in $1 whose spelling collides by letter case with a
# path in the index or the batch (see gstack-brain-sync-index.py hold). They
# leave $1 before staging, so their records stay queued. Sets ST_CASE_HELD.
hold_case_collisions() {
local paths_file="$1" out
tr -d '\r' < "$paths_file" > "$paths_file.lf" && mv -f "$paths_file.lf" "$paths_file" || return 1
out=$(python3 "$SCRIPT_DIR/gstack-brain-sync-index.py" --hold "$GSTACK_HOME" "$paths_file" "$paths_file.case") || return 1
out=${out%$'\r'}
tr -d '\r' < "$paths_file.case" > "$paths_file.case.lf" && mv -f "$paths_file.case.lf" "$paths_file.case" || return 1
ST_CASE_HELD=$(grep -c . "$paths_file.case" 2>/dev/null || true)
ST_CASE_HELD=${ST_CASE_HELD:-0}
[ "$ST_CASE_HELD" -gt 0 ] || return 0
ST_CASE_NAMES=$out
{ grep -vxF -f "$paths_file.case" "$paths_file" || true; } > "$paths_file.kept"
mv -f "$paths_file.kept" "$paths_file"
ST_NOTE="$ST_NOTE; $ST_CASE_HELD file(s) held because their path differs only by letter case from another path ($out), NOT synced and still queued: $(head -3 "$paths_file.case" | tr '\n' ' ')(repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision)"
echo "BRAIN_SYNC: holding $ST_CASE_HELD file(s) whose path differs only by letter case ($out); see $SELF_BIN --status" >&2
}
# Plan the holds for one drain (one Python pass):
# $1 scanner hits ("<family>\t<path>"), $2 groups JSON from
# compute_paths_to_stage, $3 `git cat-file --batch` output for every group
@@ -891,6 +948,15 @@ hold_flagged_paths() {
git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1
git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1
[ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; }
# A successful add need not stage the requested spelling or current bytes
# (case-insensitive indexes and assume-unchanged can both report success).
# Reuse the scanner's name snapshot; refusal preserves the index and queue.
local index_error
if [ "$rc" -eq 0 ] && ! index_error=$(python3 "$SCRIPT_DIR/gstack-brain-sync-index.py" "$GSTACK_HOME" "$paths_file" "$scan.names" 2>&1); then
write_status "error" "staged artifact validation refused; index and queue preserved; inspect before retrying"
printf '%s\n' "$index_error" >&2
return 1
fi
if [ "$rc" -eq 0 ] && [ -s "$groups_file.batch" ]; then
tr -d '\r' < "$groups_file.batch" | git -C "$GSTACK_HOME" cat-file --batch > "$scan.blobs" 2>/dev/null || rc=1
fi
@@ -965,6 +1031,7 @@ publish_removals() {
git --literal-pathspecs -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \
commit -q -m "sync: remove $n file(s) | $(date -u +%Y-%m-%dT%H:%M:%SZ)" --pathspec-from-file="$list" >/dev/null 2>&1 || return 1
guard_unpublished_range || return 2
validate_outgoing_paths || return 2
GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$(remote_host)" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME" || return 2
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
@@ -1036,6 +1103,8 @@ subcmd_once() {
gstack_brain_lock_acquire "$GSTACK_HOME" try || exit 0
trap 'gstack_brain_lock_release' EXIT INT TERM
validate_outgoing_paths || exit 0
# Convert any legacy single-file queue lines into spool records before the
# drain reads the spool (transition window for pre-spool writers).
migrate_legacy_queue
@@ -1174,13 +1243,18 @@ subcmd_once() {
write_status "error" "classification failed; queue preserved (next run retries)"
exit 0
fi
if [ -s "$paths_file" ] && ! hold_case_collisions "$paths_file"; then
write_status "error" "could not check queued paths for letter-case collisions; queue preserved (next run retries)"
exit 0
fi
if [ ! -s "$paths_file" ]; then
# Nothing stageable. Finalize the snapshot (retained entries survive;
# classified drops removed; records created after the snapshot untouched).
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
local summary
local summary idle_code=idle
summary=$(queue_summary "$class_file")
write_status "idle" "no stageable changes${summary:+ ($summary)}"
[ "$ST_CASE_HELD" -gt 0 ] && idle_code=collision
write_status "$idle_code" "no stageable changes${summary:+ ($summary)}"
exit 0
fi
local n_stageable
@@ -1224,6 +1298,8 @@ subcmd_once() {
ok_code="held"
ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes"
echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2
elif [ "$ST_CASE_HELD" -gt 0 ]; then
ok_code="collision"
fi
# A verified empty staged diff is the only "nothing to commit"; anything
@@ -1232,8 +1308,8 @@ subcmd_once() {
git -C "$GSTACK_HOME" diff --cached --quiet >/dev/null 2>&1 || cached_rc=$?
if [ "$cached_rc" -eq 0 ]; then
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
if [ "$ok_code" = "held" ]; then
write_status "held" "nothing else to sync"
if [ "$ok_code" != "ok" ]; then
write_status "$ok_code" "nothing else to sync"
else
write_status "idle" "queue drained but no new changes to commit"
fi
@@ -1278,6 +1354,7 @@ subcmd_once() {
# Nothing outside the transcript consent is pushed; a refused rewrite keeps the commit local.
guard_unpublished_range || { write_status "push_failed" "push blocked: unpushed commits hold transcript pages outside your transcript consent; commit retained locally"; finalize_queue "$snapshot_file" "$class_file" "$paths_file"; exit 0; }
validate_outgoing_paths || exit 0
# Push. On reject, fetch + merge (merge driver handles JSONL) + retry once.
local push_err
push_err=$(git -C "$GSTACK_HOME" push origin HEAD 2>&1 >/dev/null) || {
@@ -1302,6 +1379,7 @@ subcmd_once() {
local branch
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
validate_outgoing_paths || exit 0
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
@@ -1389,11 +1467,12 @@ subcmd_unskip_file() {
# Skill start's attention lines: one per row of the sync status table in this
# script's header that applies. Every value read from the status file is
# clamped (status code to the table's closed set, counts to digits,
# timestamps to their shape); the message, held paths and scanner output never
# reach this output, only fixed text and this script's absolute path.
# timestamps to their shape, case-colliding names to [A-Za-z0-9._?,-]); the
# message, held paths and scanner output never reach this output, only fixed
# text, those clamped values and this script's absolute path.
subcmd_attention() {
sync_active || exit 0
local line="" prefix code="" held="" drainable="" last_drain="" last_push="" bin="$SELF_BIN"
local line="" prefix code="" held="" drainable="" last_drain="" last_push="" case_held="" case_names="" bin="$SELF_BIN"
if [ -f "$STATUS_FILE" ]; then
line=$(head -c 65536 "$STATUS_FILE" 2>/dev/null | tr -d '\r\n')
# The counts and timestamps precede every free-text field.
@@ -1403,10 +1482,13 @@ subcmd_attention() {
drainable=$(printf '%s' "$prefix" | sed -n 's/.*"drainable": *\([0-9][0-9]*\).*/\1/p')
last_drain=$(printf '%s' "$prefix" | sed -n 's/.*"last_drain_at": *"\([^"]*\)".*/\1/p')
last_push=$(printf '%s' "$prefix" | sed -n 's/.*"last_push_at": *"\([^"]*\)".*/\1/p')
case "$code" in ok|idle|held|blocked|push_failed|error) ;; *) code="unknown" ;; esac
case_held=$(printf '%s' "$prefix" | sed -n 's/.*"case_held": *\([0-9][0-9]*\).*/\1/p')
case_names=$(printf '%s' "$prefix" | sed -n 's/.*"case_names": *"\([A-Za-z0-9._?,-]*\)".*/\1/p' | head -c 300)
case "$code" in ok|idle|held|collision|blocked|push_failed|error) ;; *) code="unknown" ;; esac
fi
case "$held" in ''|*[!0-9]*) held=0 ;; esac
case "$drainable" in ''|*[!0-9]*) drainable=0 ;; esac
case "$case_held" in ''|*[!0-9]*) case_held=0 ;; esac
local iso='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z'
case "$last_drain" in $iso) ;; *) last_drain="" ;; esac
case "$last_push" in $iso) ;; *) last_push="" ;; esac
@@ -1418,6 +1500,9 @@ subcmd_attention() {
error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;;
unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;;
esac
if [ -n "$case_names" ] || [ "$case_held" -gt 0 ]; then
echo "ARTIFACTS_SYNC: attention: case-collision held=$case_held names=$case_names. Artifact paths differ only by letter case, which a Mac or Windows disk stores as one file; sync holds $case_held queued file(s) under them and syncs everything else. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision. See the paths: $bin --status"
fi
local now cutoff oldest="" oldest_epoch
now=$(date +%s 2>/dev/null || echo "")
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Read-only verification of brain-sync's post-add index; never repairs it."""
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import unicodedata
class Refusal(Exception):
def __init__(self, message, spellings=()):
super().__init__(message)
self.spellings = spellings
def git(root, *args, data=None, allow_missing=False):
try:
result = subprocess.run(
['git', '-C', str(root), *args], input=data,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
except (OSError, subprocess.TimeoutExpired) as error:
raise Refusal('index verification could not read Git') from error
if result.returncode == 1 and allow_missing:
return b''
if result.returncode:
raise Refusal('index verification Git command failed')
return result.stdout
def prefixes(paths):
groups = {}
for path in paths:
parts = path.split('/')
for i in range(1, len(parts) + 1):
spelling = '/'.join(parts[:i])
key = unicodedata.normalize('NFC', spelling).casefold()
groups.setdefault(key, set()).add(spelling)
return groups
def names(spellings):
# Only the colliding component names, clamped to a slug's charset: skill
# start prints them, and the artifacts repo's paths are not trusted text.
leaves = sorted({re.sub(r'[^A-Za-z0-9._-]', '?', s.rsplit('/', 1)[-1])[:64] for s in spellings})
return ','.join(leaves[:4])
def first_collision(groups):
found = sorted((key.count('/'), key) for key, spellings in groups.items() if len(spellings) > 1)
return groups[found[0][1]] if found else ()
def tree_paths(root, *args):
return {p.decode('utf-8') for p in git(root, *args).split(b'\0') if p}
def refuse_new_collisions(root, paths, baseline='HEAD'):
# Compare complete component prefixes: Foo/a and foo/b collide too.
# Existing aliases are historical data; this check does not migrate them
# or block unrelated writes, but it refuses any additional spelling.
head = git(root, 'ls-tree', '-rz', '--name-only', baseline) if baseline else b''
previous = prefixes(p.decode('utf-8') for p in head.split(b'\0') if p)
groups = prefixes(paths)
for key, spellings in sorted(groups.items(), key=lambda item: (item[0].count('/'), item[0])):
if len(spellings) > 1 and not spellings <= previous.get(key, set()):
raise Refusal('new casefold path collision; inspect path spellings before retrying', spellings)
return first_collision(groups)
def hold(root, requested_file, held_file):
# A requested path is held when one of its prefixes has another spelling
# in the index or in this batch, unless its own spelling is the only one
# the index already tracks (an established project keeps syncing; the new
# alias waits). Held paths are never staged, so git cannot record them
# under the other spelling, and their queue records stay.
requested = {p for p in Path(requested_file).read_text(encoding='utf-8').replace('\r', '').split('\n') if p}
tracked_paths = tree_paths(root, 'ls-files', '-z')
tracked = prefixes(tracked_paths)
groups = prefixes(tracked_paths | requested)
held, colliding = set(), []
for path in requested:
parts = path.split('/')
for i in range(1, len(parts) + 1):
spelling = '/'.join(parts[:i])
key = unicodedata.normalize('NFC', spelling).casefold()
if len(groups[key]) > 1 and tracked.get(key) != {spelling}:
held.add(path)
colliding.append((key.count('/'), key))
break
with open(held_file, 'w', encoding='utf-8', newline='\n') as f:
f.writelines(p + '\n' for p in sorted(held))
return groups[min(colliding)[1]] if colliding else ()
def verify(root, requested_file, names_file):
requested = set(Path(requested_file).read_text(encoding='utf-8').splitlines())
requested.discard('')
entries = {}
for record in git(root, 'ls-files', '--stage', '-z').split(b'\0'):
if not record:
continue
header, name = record.split(b'\t', 1)
mode, oid, stage = header.split()
name = name.decode('utf-8')
if stage != b'0':
raise Refusal('unmerged index entries; inspect the index')
entries[name] = (mode, oid)
changed = {p.decode('utf-8') for p in Path(names_file).read_bytes().split(b'\0') if p}
refuse_new_collisions(root, set(entries) | requested)
if requested - entries.keys() or changed - requested:
raise Refusal('requested/index path mismatch; inspect exact path spellings and unrelated staged entries')
regular = []
for name in sorted(requested):
mode, oid = entries[name]
file = root / name
actual = file.lstat().st_mode
if mode == b'120000' and stat.S_ISLNK(actual):
hashed = git(root, 'hash-object', '--stdin', data=os.fsencode(os.readlink(file))).strip()
if hashed != oid:
raise Refusal('requested/index content mismatch')
elif mode in (b'100644', b'100755') and stat.S_ISREG(actual):
regular.append(name)
else:
raise Refusal('requested/index file type mismatch')
if regular:
# --stdin-paths applies the same clean filters/EOL conversion as add.
# Quote each path as Git's C-style input (including quotes/backslashes).
paths = ''.join(json.dumps(p, ensure_ascii=False) + '\n' for p in regular).encode('utf-8')
hashes = git(root, 'hash-object', '--stdin-paths', data=paths).splitlines()
if len(hashes) != len(regular) or any(entries[p][1] != oid for p, oid in zip(regular, hashes)):
raise Refusal('requested/index content mismatch; queued content is not in the index')
def outgoing(root):
if not git(root, 'rev-parse', '--verify', '--quiet', 'HEAD', allow_missing=True):
return ()
branch = git(root, 'rev-parse', '--abbrev-ref', 'HEAD').decode('utf-8').strip()
remote = 'refs/remotes/origin/' + branch
baseline = remote if git(root, 'rev-parse', '--verify', '--quiet', remote, allow_missing=True) else None
return refuse_new_collisions(root, tree_paths(root, 'ls-tree', '-rz', '--name-only', 'HEAD'), baseline)
if __name__ == '__main__':
try:
if sys.argv[1] == '--outgoing':
print(names(outgoing(Path(sys.argv[2]))))
elif sys.argv[1] == '--hold':
print(names(hold(Path(sys.argv[2]), sys.argv[3], sys.argv[4])))
else:
verify(Path(sys.argv[1]), sys.argv[2], sys.argv[3])
except (Refusal, OSError, UnicodeError, ValueError, IndexError) as error:
print(names(getattr(error, 'spellings', ())))
print('BRAIN_SYNC: staged artifact validation refused: ' + str(error), file=sys.stderr)
sys.exit(1)
+2
View File
@@ -27,6 +27,7 @@
# .brain-skip.txt — user-maintained skip list
# .brain-sync.lock.d/ — lock dir (if present)
# .brain-sync-status.json — health status
# .brain-case-check — cached case-collision verdict for HEAD
# consumers.json — consumer/reader registry
#
# What it clears (via gstack-config):
@@ -131,6 +132,7 @@ rm -f "$GSTACK_HOME/.brain-last-pull" 2>/dev/null || true
rm -f "$GSTACK_HOME/.brain-worktree-last-advance" 2>/dev/null || true
rm -f "$GSTACK_HOME/.brain-skip.txt" 2>/dev/null || true
rm -f "$GSTACK_HOME/.brain-sync-status.json" 2>/dev/null || true
rm -f "$GSTACK_HOME/.brain-case-check" 2>/dev/null || true
rm -rf "$GSTACK_HOME/.brain-sync.lock.d" 2>/dev/null || true
# ---- unregister gbrain federated source + remove worktree (best-effort) ----
+7 -2
View File
@@ -267,7 +267,7 @@ check_codex_probe() {
}
check_sync() {
local mode file json status held drainable drain push queue=0 now f detail fix="$BIN/gstack-brain-sync --status"
local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
mode=$("$BIN/gstack-config" get artifacts_sync_mode 2>/dev/null || echo off)
if [ ! -d "$STATE_ROOT/.git" ]; then
row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init"
@@ -289,8 +289,9 @@ check_sync() {
fi
json=$(head -c 16384 "$file" 2>/dev/null | tr '\n\r' ' ')
status=$(json_field status "$json")
case "$status" in ok|idle|held|blocked|push_failed|error) ;; *) status=unknown ;; esac
case "$status" in ok|idle|held|collision|blocked|push_failed|error) ;; *) status=unknown ;; esac
held=$(json_field held_count "$json" | tr -cd '0-9')
case_names=$(json_field case_names "$json" | tr -cd 'A-Za-z0-9._?,-' | head -c 300)
drainable=$(json_field drainable "$json" | tr -cd '0-9')
drain=$(epoch_of "$(json_field last_drain_at "$json")")
push=$(epoch_of "$(json_field last_push_at "$json")")
@@ -301,6 +302,10 @@ check_sync() {
blocked) row warn "artifacts sync" "status blocked (a re-scan still flags staged content)" "$fix"; return ;;
push_failed|error|unknown) row warn "artifacts sync" "status $status" "$fix"; return ;;
esac
if [ -n "$case_names" ]; then
row warn "artifacts sync" "status $status; artifact paths differ only by letter case ($case_names), see docs/troubleshooting.md#artifact-case-collision" "$fix"
return
fi
if [ "${drainable:-0}" -gt 0 ] && { [ -z "$push" ] || [ $((now - push)) -gt 86400 ]; }; then
row warn "artifacts sync" "status $status; no push for 24h while $drainable record(s) wait" "$fix"
elif [ "$queue" -gt 0 ] && [ -n "$drain" ] && [ $((now - drain)) -gt 86400 ]; then
+1
View File
@@ -44,6 +44,7 @@ cause, the held files and the fix.
| `error` | The drain stopped before committing and kept the queue | `status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: <bin> --status` | The cause `--status` names: a git index lock, a commit hook, a `git add` failure |
| `unknown` | The status file holds a status code outside this table | `status=unknown. The sync status file is unreadable. See it: <bin> --status` | Run `<bin> --once` to rewrite it |
| stale-push | No push for 24 hours while the drain's `drainable` count is above zero | `stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: <bin> --status` | The waiting reason `--status` names (a coupled group waiting for a consistent generation, a `git add` failure) |
| case-collision | `case_names` is set: artifact paths differ only by letter case, so the drain holds queued files under them | `case-collision held=<N> names=<names>. Artifact paths differ only by letter case, which a Mac or Windows disk stores as one file; sync holds <N> queued file(s) under them and syncs everything else. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision. See the paths: <bin> --status` | Keep one spelling: the manual repair in [troubleshooting](troubleshooting.md#artifact-case-collision) |
| stale-drain | `last_drain_at` older than 24 hours while the queue holds records | `stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: <bin> --status` | If no gstack process is running, remove `~/.gstack/.brain-sync.lock.d`, then run `<bin> --once` |
`<bin>` is the absolute path, for example
+5 -2
View File
@@ -90,8 +90,11 @@ On machine B:
gstack-brain-sync --status
```
Shows: the status (`ok`, `idle`, `held`, `blocked`, `push_failed`,
`error`), files held back by the secret scan with their fixes, the
Shows: the status (`ok`, `idle`, `held`, `collision`, `blocked`,
`push_failed`, `error`), files held back by the secret scan with their
fixes, files held because their path differs only by letter case from
another path (`case_held`, `case_names`; see
[troubleshooting](troubleshooting.md#artifact-case-collision)), the
`drainable` count, the last sync and push times, pending queue depth, and
the current privacy mode.
+72
View File
@@ -630,6 +630,78 @@ has nothing to calibrate from. It no longer says "calibrated".
**Fix.** Answer more registered questions, then `gstack-developer-profile --derive`.
<a id="artifact-case-collision"></a>
### `ARTIFACTS_SYNC: attention: case-collision held=<N> names=<names>. Artifact paths differ only by letter case, ...`
**Meaning.** The artifacts repo holds paths that differ only by letter case,
such as `projects/MyApp/` and `projects/myapp/`. A Mac or Windows disk stores
them as one file, so git there would stage a write under the other spelling,
and sync would report success while one spelling stays modified and the other
machines read stale data (#3220). Sync now holds every queued file under such a
path instead of staging it: the file stays queued, everything else keeps
syncing, and the status is `collision` rather than `ok`. When only one
spelling is tracked, that spelling keeps syncing and only the new alias is
held; when neither is tracked yet, both wait. `<names>` are the colliding folder or file names; `gstack-brain-sync
--status` lists the held paths in `message`. A local commit that would push a
new alias to the remote (two machines created both spellings at once) is
refused with status `error` and kept locally.
Nothing is repaired automatically. Slug resolution and skill start are
unaffected; only the sync holds files.
**Fix.** Keep one spelling, by hand, on one machine:
1. Choose the spelling to keep: the one `gstack-slug` prints in that project
today. If the project resolves to different spellings (a remote URL typed
two ways, a folder reached as `~/code/MyApp` and `~/code/myapp`), make it
stable first: fix the remote URL's case, or set `GSTACK_PROJECT_SLUG`.
2. Fold the other spelling's tracked files into the kept one. Read both
spellings from git, because a Mac's working tree shows only one of them,
and the working files too, because they hold the held, unsynced writes.
This loop covers the dropped spelling's tracked files and its untracked
ones (held writes that never synced). It folds JSONL logs (concatenated,
duplicate lines dropped) and prints every other file for you to decide:
```bash
cd ~/.gstack
keep=projects/MyApp; drop=projects/myapp
{ git -c core.ignorecase=false ls-files -- "$drop"; git -c core.ignorecase=false ls-files --others -- "$drop"; } | sort -u | while IFS= read -r f; do
rel=${f#"$drop"/}
case "$rel" in *.jsonl) ;; *) echo "decide by hand: $f"; continue ;; esac
{ git show ":$keep/$rel"; git show ":$f"; cat "$keep/$rel" "$f"; } 2>/dev/null | awk '!seen[$0]++' > "$HOME/.gstack-fold.tmp"
mkdir -p "$keep/$(dirname "$rel")" && mv "$HOME/.gstack-fold.tmp" "$keep/$rel"
git -c core.ignorecase=false add -f -- "$keep/$rel"
git -c core.ignorecase=false rm -q --cached --ignore-unmatch -- "$f"
echo "folded: $keep/$rel"
done
```
For each file it prints (Markdown, JSON snapshots such as
`decisions.active.json`), compare `git show ":$keep/<file>"` with
`git show ":$drop/<file>"`, write the content to keep to `"$keep/<file>"`,
then run the same `git -c core.ignorecase=false add -f -- "$keep/<file>"`
and `git -c core.ignorecase=false rm -q --cached --ignore-unmatch -- "$drop/<file>"`. Never
concatenate a JSON snapshot. Then commit and push:
```bash
git -c core.ignorecase=false commit -m "repair: fold $drop into $keep"
git push origin HEAD
```
On Linux the two folders are separate on disk: after the commit, check
that `git -c core.ignorecase=false ls-files --others -- "$drop"` lists
nothing you have not folded or moved, then remove the dropped folder
(`rm -r "$drop"`). On a Mac or Windows they are the same folder, so do
not remove anything.
3. Clear the held records of the dropped spelling and queue the kept files:
`gstack-brain-sync --skip-file <dropped path>` for each held path that
`--status` lists, then `gstack-brain-enqueue <kept path>` for each
`folded:` line and each file you moved by hand, and
`gstack-brain-sync --once`.
4. On every other machine, let the next skill start pull the repair (or run
`git -C ~/.gstack pull`). The line disappears once no tracked path
differs only by case.
<a id="skill-start-former-bucket"></a>
### `gstack: earlier data for this project is in projects/<slug> (a worktree's old bucket). Merge it: ...`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.125",
"version": "1.91.126",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+215
View File
@@ -610,6 +610,221 @@ describe('gstack-brain-sync per-file hold', () => {
expect(spoolFiles()).toHaveLength(3);
}, 120_000);
test('successful add without an index entry keeps the queue and refuses publication', () => {
init();
write(CLEAN, '# queued plan\n');
const before = git(['rev-parse', 'HEAD']).stdout;
const shim = gitShim('case " $* " in *" add "*) exit 0;; esac');
expect(drain(shim.env).status).toBe(0);
expect(statusJson().status).toBe('error');
expect(statusJson().message).toContain('staged artifact validation refused');
expect(spoolText()).toContain(CLEAN);
expect(git(['rev-parse', 'HEAD']).stdout).toBe(before);
expect(remoteFiles()).not.toContain(CLEAN);
});
test('assume-unchanged cannot silently acknowledge stale indexed content', () => {
init();
write(CLEAN, '# first version\n');
drain();
expect(git(['update-index', '--assume-unchanged', CLEAN]).status).toBe(0);
write(CLEAN, '# second version\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('error');
expect(spoolText()).toContain(CLEAN);
expect(remoteShow(CLEAN).stdout).toBe('# first version\n');
});
test('unrelated staged entries are preserved and never included in a sync commit', () => {
init();
write(CLEAN, '# queued plan\n');
const extra = 'projects/p/ceo-plans/manual.md';
write(extra, '# manually staged\n', false);
expect(git(['add', '-f', '--', extra]).status).toBe(0);
const result = drain();
expect(result.status).toBe(0);
expect(statusJson().status).toBe('error');
expect(spoolText()).toContain(CLEAN);
expect(staged()).toContain(extra);
expect(staged()).toContain(CLEAN);
expect(remoteFiles()).not.toContain(extra);
expect(remoteFiles()).not.toContain(CLEAN);
});
test('staged content verification respects Git clean conversion', () => {
init();
fs.writeFileSync(path.join(tmpHome, '.gitattributes'), '*.md text eol=lf\n');
write(CLEAN, '# CRLF plan\r\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
expect(remoteShow(CLEAN).stdout).toBe('# CRLF plan\n');
expect(spoolFiles()).toHaveLength(0);
});
function seedIndexedPaths(paths: string[]) {
const oid = spawnSync('git', ['-C', tmpHome, 'hash-object', '-w', '--stdin'], {
input: '# historical artifact\n', encoding: 'utf-8', timeout: 30_000,
});
expect(oid.status).toBe(0);
for (const p of paths) {
expect(git(['update-index', '--add', '--cacheinfo', `100644,${oid.stdout.trim()},${p}`]).status).toBe(0);
}
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'historical paths']).status).toBe(0);
expect(git(['push', '-q', 'origin', 'HEAD']).status).toBe(0);
}
for (const [oldPath, newPath] of [
['projects/P/ceo-plans/old.md', CLEAN],
['projects/p/ceo-plans/Foo/old.md', 'projects/p/ceo-plans/foo/new.md'],
['projects/Straße/ceo-plans/old.md', 'projects/STRASSE/ceo-plans/new.md'],
]) {
test(`a new casefold prefix alias is held, never staged: ${oldPath}`, () => {
init();
// Create the historical tree directly so this works on either a case-
// sensitive or insensitive host, without materializing conflicting dirs.
seedIndexedPaths([oldPath]);
expect(git(['config', 'core.ignorecase', 'false']).status).toBe(0);
write(newPath, '# queued plan\n');
const before = git(['rev-parse', 'HEAD']).stdout;
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('collision');
expect(s.case_held).toBe(1);
expect(spoolText()).toContain(newPath);
expect(git(['rev-parse', 'HEAD']).stdout).toBe(before);
expect(remoteFiles()).not.toContain(newPath);
expect(staged()).toBe('');
});
}
test('an unchanged historical collision does not block an unrelated artifact', () => {
init();
seedIndexedPaths(['projects/OLD/ceo-plans/a.md', 'projects/old/ceo-plans/b.md']);
write(CLEAN, '# independent plan\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
expect(statusJson().case_held).toBe(0);
expect(statusJson().case_names).toBe('OLD,old');
expect(remoteFiles()).toContain(CLEAN);
expect(spoolFiles()).toHaveLength(0);
});
// #3220: two spellings of one project are both tracked (a case-sensitive
// producer committed both). A write to either is held instead of staged, so
// a case-insensitive checkout cannot record it under the other spelling and
// report ok; every other file still syncs.
const WS_UPPER = 'projects/Example_Workspace/learnings.jsonl';
const WS_LOWER = 'projects/Example_workspace/learnings.jsonl';
test('#3220: a write into a tracked case-colliding project is held; the rest syncs and nothing reports success', () => {
init();
seedIndexedPaths([WS_UPPER, WS_LOWER]);
write(WS_LOWER, '{"key":"c","insight":"new on mac"}\n');
write(CLEAN, '# independent plan\n');
const result = drain();
expect(result.status).toBe(0);
expect(result.stderr).toContain('differs only by letter case (Example_Workspace,Example_workspace)');
expect(remoteFiles()).toContain(CLEAN);
expect(remoteShow(WS_LOWER).stdout).toBe('# historical artifact\n');
expect(staged()).toBe('');
expect(spoolText()).toContain(WS_LOWER);
expect(spoolText()).not.toContain(CLEAN);
const s = statusJson();
expect(s.status).toBe('collision');
expect(s.case_held).toBe(1);
expect(s.case_names).toBe('Example_Workspace,Example_workspace');
expect(s.message).toContain(WS_LOWER);
expect(s.message).toContain('docs/troubleshooting.md#artifact-case-collision');
expect(run(['gstack-brain-sync', '--attention']).stdout.trim()).toBe(
`ARTIFACTS_SYNC: attention: case-collision held=1 names=Example_Workspace,Example_workspace. Artifact paths differ only by letter case, which a Mac or Windows disk stores as one file; sync holds 1 queued file(s) under them and syncs everything else. Repair: https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#artifact-case-collision. See the paths: ${SELF} --status`,
);
// Held, not dropped: the next drain holds it again and still never says ok.
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('collision');
expect(spoolText()).toContain(WS_LOWER);
});
test('publish-removals refuses to push a retained commit that adds a case alias', () => {
init();
const victim = 'projects/other/ceo-plans/remove.md';
seedIndexedPaths(['projects/P/ceo-plans/old.md', victim]);
write('projects/P/ceo-plans/old.md', '# historical artifact\n', false);
const alias = 'projects/p/ceo-plans/local.md';
write(alias, '# unsafe local alias\n', false);
expect(git(['add', '-f', '--', alias]).status).toBe(0);
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'retained local alias']).status).toBe(0);
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('error');
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).status).toBe(1);
expect(remoteFiles()).not.toContain(alias);
expect(remoteFiles()).toContain(victim);
});
test('the cached outgoing verdict is re-checked when the remote baseline moves', () => {
init();
const initial = git(['rev-parse', 'HEAD']).stdout.trim();
seedIndexedPaths(['projects/MyApp/ceo-plans/a.md', 'projects/myapp/ceo-plans/b.md']);
expect(drain().status).toBe(0);
expect(statusJson().status).not.toBe('error');
expect(git(['--git-dir=' + bareRemote, 'update-ref', 'refs/heads/main', initial]).status).toBe(0);
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('error');
expect(statusJson().case_names).toBe('MyApp,myapp');
});
test('a tracked spelling keeps syncing while only the new case alias is held', () => {
init();
seedIndexedPaths(['projects/MyApp/ceo-plans/a.md']);
write('projects/MyApp/ceo-plans/a.md', '# updated\n');
write('projects/myapp/ceo-plans/b.md', '# other project\n');
expect(drain().status).toBe(0);
expect(remoteShow('projects/MyApp/ceo-plans/a.md').stdout).toBe('# updated\n');
expect(remoteFiles()).not.toContain('projects/myapp/ceo-plans/b.md');
const s = statusJson();
expect(s.status).toBe('collision');
expect(s.case_held).toBe(1);
expect(s.case_names).toBe('MyApp,myapp');
expect(spoolText()).toContain('projects/myapp/ceo-plans/b.md');
});
test('without a case collision the status carries no case fields and skill start no case line', () => {
init();
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(s.case_held).toBe(0);
expect(s.case_names).toBe('');
expect(run(['gstack-brain-sync', '--attention']).stdout).toBe('');
});
test('a retry merge introducing casefold aliases stays local and keeps the queue', () => {
init();
const peer = fs.mkdtempSync(path.join(tmpHome, 'peer-'));
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
const peerPath = 'projects/P/ceo-plans/remote.md';
fs.mkdirSync(path.dirname(path.join(peer, peerPath)), { recursive: true });
fs.writeFileSync(path.join(peer, peerPath), '# remote plan\n');
expect(git(['add', '-f', '--', peerPath], peer).status).toBe(0);
expect(git(['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.com', 'commit', '-qm', 'remote plan'], peer).status).toBe(0);
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
expect(git(['config', 'core.ignorecase', 'false']).status).toBe(0);
// The retry merge commits with the ambient identity; a CI runner has none.
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
write(CLEAN, '# local plan\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('error');
expect(statusJson().case_names).toBe('P,p');
expect(spoolText()).toContain(CLEAN);
expect(remoteFiles()).toContain(peerPath);
expect(remoteFiles()).not.toContain(CLEAN);
// A following empty/ordinary retry must not auto-push the retained merge.
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('error');
expect(remoteFiles()).not.toContain(CLEAN);
});
test('a git add failure is reported, never swallowed', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root)
init();
+14
View File
@@ -263,6 +263,20 @@ describe('gstack-doctor', () => {
expect(doctor(f).row('artifacts sync').state).toBe('ok');
});
test('artifacts sync: case-colliding paths warn with the clamped names, held or not', () => {
const f = makeFixture();
syncOn(f, { status: 'collision', message: 'held projects/myapp/x.md', case_held: 1, case_names: 'MyApp,myapp', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
const held = doctor(f);
expect(held.row('artifacts sync').state).toBe('warn');
expect(held.row('artifacts sync').detail).toBe('status collision; artifact paths differ only by letter case (MyApp,myapp), see docs/troubleshooting.md#artifact-case-collision');
expect(held.out).not.toContain('projects/myapp');
syncOn(f, { status: 'ok', message: 'pushed 1 file(s)', case_held: 0, case_names: 'OLD,old', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
expect(doctor(f).row('artifacts sync').state).toBe('warn');
syncOn(f, { status: 'ok', message: 'pushed 1 file(s)', case_held: 0, case_names: '', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) });
expect(doctor(f).row('artifacts sync').state).toBe('ok');
});
test('artifacts sync: stale push and stale drain warn, a fresh push clears them', () => {
const f = makeFixture();
syncOn(f, { status: 'ok', message: 'x', drainable: 4, last_drain_at: iso(60), last_push_at: iso(90_000) });
+3 -2
View File
@@ -611,8 +611,9 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => {
status: code, held_count: 0, drainable: 0, last_drain_at: NOW(), last_push_at: NOW(), ts: NOW(), held: [], message: 'm', ...extra,
});
const FIXTURES: Record<string, { status: () => Record<string, unknown>; queued?: number; n?: number }> = {
const FIXTURES: Record<string, { status: () => Record<string, unknown>; queued?: number; n?: number; names?: string }> = {
held: { status: () => status('held', { held_count: 2 }), n: 2 },
'case-collision': { status: () => status('collision', { case_held: 2, case_names: 'MyApp,myapp' }), n: 2, names: 'MyApp,myapp' },
blocked: { status: () => status('blocked') },
push_failed: { status: () => status('push_failed') },
error: { status: () => status('error') },
@@ -640,7 +641,7 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => {
expect(fx).toBeDefined();
const gh = syncHome(fx.status(), fx.queued ?? 0);
try {
const expected = row.line.replaceAll('<bin>', SYNC).replaceAll('<N>', String(fx.n ?? 0));
const expected = row.line.replaceAll('<bin>', SYNC).replaceAll('<N>', String(fx.n ?? 0)).replaceAll('<names>', fx.names ?? '');
expect(attention(runStart([], { GSTACK_HOME: gh }))).toEqual([expected]);
} finally {
fs.rmSync(gh, { recursive: true, force: true });