B5: learnings and timeline reads fail loudly when bun is missing

gstack-learnings-search and gstack-timeline-read ended in
'2>/dev/null || exit 0', so a missing bun or a crashed embedded script
printed nothing and exited 0, identical to 'nothing recorded'. Both now
exit 127 with '<script>: bun not found on PATH, so ... could not be
read. Fix: install Bun (https://bun.sh), then re-run ./setup.' and let
an embedded-script failure exit non-zero with its stderr. A successful
run with no matches still exits 0 with empty output. The generated
callers that print 'LEARNINGS: unavailable (...)' belong to the
generator lane.

Co-authored-by: loulanyue <260355617@qq.com>
This commit is contained in:
garrytanandloulanyue committed 2026-10-03 21:04:53 +00:00
1 parent 373118784a
commit 85c9d4cebd
3 files changed
+69 -6

No files matched your search

+9 -2
View File
@@ -4,7 +4,9 @@
#
# Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay,
# resolves duplicates (latest winner per key+type), and outputs formatted text.
# Exit 0 silently if no learnings file exists.
# Exit 0 silently if no learnings file exists or nothing matches. Missing bun
# exits 127 and a failed read exits non-zero with its stderr (#2790): "could
# not read" must never look like "nothing recorded".
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -45,6 +47,11 @@ if [ ! -f "$LEARNINGS_FILE" ] && [ ${#CROSS_FILES[@]} -eq 0 ]; then
exit 0
fi
command -v bun >/dev/null 2>&1 || {
echo "gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2
exit 127
}
emit_tagged_file() {
local tag="$1"
local file="$2"
@@ -161,4 +168,4 @@ for (const [t, arr] of Object.entries(byType)) {
}
console.log('');
}
" 2>/dev/null || exit 0
"
+9 -3
View File
@@ -4,7 +4,8 @@
#
# Session timeline: local-only, never sent anywhere.
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
# Exit 0 silently if no timeline file exists.
# Exit 0 silently if no timeline file exists or nothing matches. Missing bun
# exits 127 and a failed read exits non-zero with its stderr (#2790).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -31,7 +32,12 @@ if [ ! -f "$TIMELINE_FILE" ]; then
exit 0
fi
cat "$TIMELINE_FILE" 2>/dev/null | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
command -v bun >/dev/null 2>&1 || {
echo "gstack-timeline-read: bun not found on PATH, so the timeline could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2
exit 127
}
cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
const since = process.env.GSTACK_TIMELINE_SINCE || '';
const branch = process.env.GSTACK_TIMELINE_BRANCH || '';
@@ -95,4 +101,4 @@ for (const e of recent) {
const outcome = e.outcome ? ' [' + e.outcome + ']' : '';
console.log('- ' + ts + ' /' + e.skill + ' ' + e.event + outcome + dur + (e.branch ? ' on ' + e.branch : ''));
}
" 2>/dev/null || exit 0
"
+51 -1
View File
@@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import { execFileSync, spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-learnings-search');
@@ -91,3 +91,53 @@ describe('gstack-learnings-search cross-project trust gating', () => {
expect(out).not.toContain('foreign-legacy');
});
});
// B5 (#2790): both query scripts ended in `2>/dev/null || exit 0`, so a
// missing bun or a crashed embedded script printed nothing and exited 0,
// exactly like "nothing recorded".
describe('B5: query scripts never report a failed read as an empty result', () => {
const TOOLS = ['bash', 'sh', 'env', 'dirname', 'basename', 'git', 'tr', 'sed', 'cat', 'head', 'tail', 'grep', 'find',
'ls', 'mkdir', 'mktemp', 'mv', 'rm', 'cp', 'awk', 'wc', 'sort', 'uname', 'date', 'cut', 'readlink', 'realpath', 'stat', 'touch', 'id', 'printf'];
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-nobun-'));
const failDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-failbun-'));
for (const tool of TOOLS) {
const r = spawnSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf-8', timeout: 5000 });
const where = (r.stdout || '').trim();
if (where.startsWith('/')) fs.symlinkSync(where, path.join(shimDir, tool));
}
fs.writeFileSync(path.join(failDir, 'bun'), '#!/bin/sh\necho "embedded script exploded" >&2\nexit 3\n', { mode: 0o755 });
const timelineDir = path.join(tmpHome, 'projects', slug);
afterAll(() => {
fs.rmSync(shimDir, { recursive: true, force: true });
fs.rmSync(failDir, { recursive: true, force: true });
});
function runWith(script: string, pathValue: string, args: string[] = []) {
return spawnSync('bash', [path.join(ROOT, 'bin', script), ...args], {
timeout: 30_000, env: { ...process.env, GSTACK_HOME: tmpHome, PATH: pathValue }, cwd: tmpCwd, encoding: 'utf-8',
});
}
for (const script of ['gstack-learnings-search', 'gstack-timeline-read']) {
test(`${script}: bun missing exits 127 with the fix line`, () => {
fs.writeFileSync(path.join(timelineDir, 'timeline.jsonl'), JSON.stringify({ ts: '2026-05-01T00:00:00Z', skill: 'ship', event: 'started', branch: 'main' }) + '\n');
const r = runWith(script, shimDir);
expect(r.status).toBe(127);
expect(r.stdout).toBe('');
expect(r.stderr).toContain(`${script}: bun not found on PATH`);
expect(r.stderr).toContain('Fix: install Bun (https://bun.sh), then re-run ./setup.');
});
test(`${script}: a failing embedded script exits non-zero and keeps its stderr`, () => {
const r = runWith(script, `${failDir}:${process.env.PATH}`);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain('embedded script exploded');
});
}
test('a successful search with no matches still exits 0 with empty output', () => {
const r = runWith('gstack-learnings-search', process.env.PATH!, ['--query', 'zzz-no-such-token']);
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
});
});