mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
Five specialist reviewers (testing/maintainability/security/performance/ data-migration) on the reconciled diff; every finding applied: Performance: the alias arm now resolves all granted sources CONCURRENTLY (a federated caller paid M sequential RTTs per turn — ~355ms at 5 sources cross-region, inside the reflex's 1.5s budget); watch's session dedupe is O(1) Set membership instead of a monotonically growing priorContext string (O(T²) over a long-lived session); getWindowTurns iterates from the tail (per-turn cost no longer grows with session length); the resolver's provenance maps fold into the existing candidate pass. Security: volunteer_context clamps caller-supplied attribution at the trust boundary — session_id capped at 256 chars (a read-scoped token could bank ~1MiB TEXT per request, retained 90 days), turn logged only when a safe integer (a non-integer threw inside the batched INSERT and silently dropped the whole batch). The privacy comments now state precisely what rationale may contain (the matched entity's surface form — which by construction resolved to an existing alias/title/slug — never free conversation text). Maintainability: TURN_PREFIX_RE + formatVolunteeredPage exported from volunteer.ts and shared by watch/cli (the two surfaces can no longer drift); volunteerEventRowsFrom is the single VolunteerEventRow assembly site for all three channels; watch's window default now honors the same retrieval_reflex_window_turns config knob the reflex reads; the stale pre-v116 comments swept to pre-v117. Testing: the two flake-class CRITICALs fixed (pipe test asserts the backstop banner instead of a cold-CI-hostile 9s wall bound; the SIGINT test waits on watch's new machine-readable ready line instead of a fixed 15s sleep — 2.5s and deterministic now); new coverage for the sink's timeout branch + ghost-reference drop, watch per-turn fail-open, untrusted knob clamps (min_confidence/max_pages/days), window-cap ordering (newest user mention survives), serve-IPC suppression passthrough + channel=reflex logging, windowTurnCount edge semantics, and structural pins for the sink registration + cycle purge wiring. The exit-verdict pin's grep is now operator/whitespace-tolerant. Deferred with TODOs: resolver index shapes for the per-turn query; batched first-prune after a long dream-cycle gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
/**
|
|
* v0.43 (#2095) — `gbrain watch` SIGINT lifecycle. SERIAL: spawns a real CLI
|
|
* subprocess with a tmpdir brain (the parallel unit shards flake on
|
|
* concurrent subprocess spawns — same isolation rationale as
|
|
* apply-migrations-pglite-spawn.serial.test.ts).
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
|
|
describe('gbrain watch — SIGINT lifecycle (real subprocess)', () => {
|
|
test('SIGINT mid-stream closes the stream and exits cleanly (drain path, exit 0)', async () => {
|
|
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('fs');
|
|
const { join, resolve } = await import('path');
|
|
const { tmpdir } = await import('os');
|
|
const REPO = resolve(import.meta.dir, '..');
|
|
const home = mkdtempSync(join(tmpdir(), 'gbrain-watch-sigint-'));
|
|
try {
|
|
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
|
writeFileSync(
|
|
join(home, '.gbrain', 'config.json'),
|
|
JSON.stringify({
|
|
engine: 'pglite',
|
|
database_path: join(home, '.gbrain', 'brain.pglite'),
|
|
embedding_dimensions: 1536,
|
|
}) + '\n',
|
|
);
|
|
// Piped stdin that NEVER reaches EOF — only SIGINT can end the stream.
|
|
const proc = Bun.spawn(['bun', 'run', join(REPO, 'src', 'cli.ts'), 'watch'], {
|
|
cwd: REPO,
|
|
env: { ...process.env, HOME: home, GBRAIN_HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
|
|
stdin: 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
proc.stdin.write('user: nothing relevant here\n');
|
|
await proc.stdin.flush();
|
|
// Readiness probe: watch prints "[watch] session <id> ready" on stderr
|
|
// once engine + source resolution are done and the stdin loop is live.
|
|
// A fixed sleep raced cold PGLite init (other tests budget 60s for it)
|
|
// — SIGINT before the handler registers means default-disposition kill.
|
|
const stderrChunks: string[] = [];
|
|
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
|
const decoder = new TextDecoder();
|
|
const deadline = Date.now() + 90_000;
|
|
let ready = false;
|
|
while (Date.now() < deadline) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
if (stderrChunks.join('').includes('ready')) { ready = true; break; }
|
|
}
|
|
expect(ready).toBe(true);
|
|
// Brief settle so the first turn's volunteerContext round-trip is in
|
|
// flight or done, then interrupt mid-stream.
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
proc.kill('SIGINT');
|
|
const killer = setTimeout(() => {
|
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
}, 30_000);
|
|
const exitCode = await proc.exited;
|
|
clearTimeout(killer);
|
|
// Drain the rest of stderr for the banner assertion.
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
stderrChunks.push(decoder.decode(value, { stream: true }));
|
|
}
|
|
const stderr = stderrChunks.join('');
|
|
// Clean drain-then-exit: no force-exit banner, no SIGKILL (137), exit 0.
|
|
expect(stderr).not.toContain('force-exiting');
|
|
expect(exitCode).toBe(0);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
}, 120_000);
|
|
});
|