W4: gstack-brain-sync holds staged transcript pages without consent

Queued transcripts/ pages stay queued and on disk unless
transcript_ingest_mode is recent or all; other classes still push.
Test: brain-sync.test.ts push-filter case (absent, off, legacy A, then recent).
This commit is contained in:
garrytan committed 2026-10-03 00:08:46 +00:00
1 parent 7da92a7741
commit cece11076e
2 files changed
+55 -3

No files matched your search

+22 -3
View File
@@ -245,10 +245,11 @@ compute_paths_to_stage() {
local mode="$1"
local class_file="${2:-}"
local snapshot_file="${3:-}"
python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" <<'PYEOF'
local transcripts_ok="${4:-0}"
python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" <<'PYEOF'
import sys, json, os, fnmatch, glob
gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file = sys.argv[1:9]
gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file, transcripts_ok = sys.argv[1:10]
def load_lines(path):
try:
@@ -365,6 +366,11 @@ for p in sorted(queue_paths):
if not path_matches_any(p, allowlist_globs):
classified["dropped"]["unmatched"].append(p)
continue
# Staged transcript pages need transcript consent; without it they stay
# queued (retained) and on disk, and push once the user consents.
if transcripts_ok != "1" and p.split("/")[0] == "transcripts":
classified["retained"].append(p)
continue
# Must survive privacy mode filter — held entries STAY QUEUED (retained):
# they would sync under a higher artifacts_sync_mode, and reporting them
# as "no allowlisted changes" was #2549's misattribution.
@@ -387,6 +393,17 @@ for p in final:
PYEOF
}
# Transcript consent: transcript_ingest_mode is recent or all (trimmed, any
# case). An absent key, off, a legacy value or anything else is not consent.
# Same rule as normalizeTranscriptConsent in gstack-memory-ingest.ts.
transcripts_consented() {
"$CONFIG_BIN" has transcript_ingest_mode 2>/dev/null || return 1
local v
v=$("$CONFIG_BIN" get transcript_ingest_mode 2>/dev/null || true)
v=$(printf '%s' "$v" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$v" = "recent" ] || [ "$v" = "all" ]
}
# Finalize the drain: delete exactly the spool record files this drain
# consumed (per the snapshot manifest) AND positively classified. Deletion is
# EXPLICIT-DELETE-ONLY: a record is unlinked only when its path appears in
@@ -659,7 +676,9 @@ subcmd_once() {
# snapshot manifest but no classification. Finalizing on that state is what
# used to wipe the queue — so on a nonzero exit, warn loudly, do NOT call
# finalize_queue, and leave everything queued for the next drain.
if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" > "$paths_file"; then
local transcripts_ok=0
transcripts_consented && transcripts_ok=1
if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" > "$paths_file"; then
echo "BRAIN_SYNC: warning: queue classification failed — no records consumed; next run re-drains" >&2
write_status "error" "classification failed; queue preserved (next run retries)"
exit 0
+33
View File
@@ -294,6 +294,39 @@ describe('init + sync + restore round-trip', () => {
expect(log.stdout).toMatch(/sync: 1 file/);
});
test('staged transcript pages stay queued without transcript consent; other classes still push', () => {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', 'full']);
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'),
'{"skill":"x","insight":"y","ts":"2026-04-22T10:00:00Z"}\n');
const page = 'transcripts/run-1-1/session.md';
fs.mkdirSync(path.join(tmpHome, 'transcripts', 'run-1-1'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, page), '# Session\n\nhello\n');
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
run(['gstack-brain-enqueue', page]);
const remoteFiles = () =>
spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(remoteFiles()).toContain('projects/p/learnings.jsonl');
expect(remoteFiles()).not.toContain(page);
expect(spoolText()).toContain(page);
expect(fs.existsSync(path.join(tmpHome, page))).toBe(true);
// A stored off or legacy value is not consent either.
for (const value of ['off', 'A']) {
fs.appendFileSync(path.join(tmpHome, 'config.yaml'), `transcript_ingest_mode: ${value}\n`);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(remoteFiles()).not.toContain(page);
}
expect(run(['gstack-config', 'set', 'transcript_ingest_mode', 'recent']).status).toBe(0);
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
expect(remoteFiles()).toContain(page);
expect(spoolText()).not.toContain(page);
});
test('restore round-trip: writes on machine A visible on machine B', () => {
// Machine A.
run(['gstack-artifacts-init', '--remote', bareRemote]);