Files
gbrain/test/cli-should-force-exit.test.ts
T
Garry TanandClaude Fable 5 f77740599e feat(cli): gbrain watch — push transport over stdin (#2095)
The issue's headline: the brain volunteers pages as the conversation flows,
instead of waiting to be asked. `some-transcript-feed | gbrain watch` reads
turns line-by-line ('user:'/'assistant:' prefixes set the role; unprefixed
lines are user turns), keeps a rolling window (--window-turns, default 4),
and streams confidence-gated pointers with rationales to stdout (--json for
JSONL). Session dedupe rides the core's slug-only suppression — a slug is
volunteered at most once per session. Events log on channel 'watch' with
session_id + turn through the drained sink.

Lifecycle: watch BLOCKS in the stdin iteration (like `jobs work`) — an
interactive TTY stays alive until Ctrl-C/Ctrl-D, piped input ends at EOF —
so it is deliberately NOT in DAEMON_COMMANDS (reverts the commit-1
placeholder): when main() resolves the work is over, the CLI_ONLY finally
drains volunteer events via drainThenDisconnect, and the entrypoint
flush-exit ends the process. Keeping it in the daemon set would have made
the piped EOF path hang on lingering sockets — the exact #2084 class.
SIGINT closes the stream and flows through the same drain path instead of
killing mid-write. Per-turn resolution failures are fail-open (the stream
never dies on a transient DB error).

Full wiring (eng-review D12): CLI_ONLY + CLI_ONLY_SELF_HELP (WATCH_HELP) +
THIN_CLIENT_REFUSED_COMMANDS (thin clients use the volunteer_context MCP
op) + main --help entry.

Tests: 18 green — help, per-turn volunteering + clean EOF return, rolling
window via assistant-introduced entity, session dedupe, --json shape with
turn attribution, channel-watch event rows, --min-confidence gate, CRLF/
blank tolerance, daemon-gate semantics. Live smoke: piped `gbrain watch`
on a fresh PGLite brain exits 0 at EOF with no force-exit banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:51:06 -07:00

90 lines
4.3 KiB
TypeScript

/**
* v0.41.8.0 — shouldForceExitAfterMain argv parsing unit tests.
*
* The function is the safety guard that protects daemons from the
* narrow timeout-only force-exit in cli.ts. If it misclassifies
* `gbrain serve` as a non-daemon (or any other intentional long-
* runner that gets added later), the daemon dies after the first
* request. Pure function; testable in isolation; deserves its own
* unit cases beyond the e2e daemon-survival smoke.
*/
import { describe, test, expect } from 'bun:test';
import { shouldForceExitAfterMain } from '../src/core/cli-force-exit.ts';
describe('shouldForceExitAfterMain — daemon survival gate', () => {
test('returns false for bare `serve` (stdio daemon)', () => {
expect(shouldForceExitAfterMain(['serve'])).toBe(false);
});
test('returns false for `serve --http --port 3131`', () => {
expect(shouldForceExitAfterMain(['serve', '--http', '--port', '3131'])).toBe(false);
});
test('returns false even when global flags precede `serve`', () => {
// `--quiet`, `--progress-json`, `--progress-interval=Nms` are stripped
// by parseGlobalFlags BEFORE command dispatch — but shouldForceExitAfterMain
// may be called with the raw argv. The .find skips flags, so the first
// positional should resolve to the actual command regardless of global
// flag position. This is the load-bearing case for `gbrain --quiet serve`.
expect(shouldForceExitAfterMain(['--quiet', 'serve'])).toBe(false);
expect(shouldForceExitAfterMain(['--progress-json', 'serve', '--http'])).toBe(false);
expect(shouldForceExitAfterMain(['--progress-interval=500', '--quiet', 'serve'])).toBe(false);
});
test('returns true for op commands (search/query/get)', () => {
expect(shouldForceExitAfterMain(['search', 'foxtrot'])).toBe(true);
expect(shouldForceExitAfterMain(['query', 'where is foo'])).toBe(true);
expect(shouldForceExitAfterMain(['get', 'people/alice'])).toBe(true);
});
test('returns true for non-daemon CLI commands', () => {
expect(shouldForceExitAfterMain(['stats'])).toBe(true);
expect(shouldForceExitAfterMain(['doctor'])).toBe(true);
expect(shouldForceExitAfterMain(['sync', '--no-pull'])).toBe(true);
expect(shouldForceExitAfterMain(['embed', '--stale'])).toBe(true);
});
test('returns true for empty argv (no command)', () => {
// Defensive: with no positional, `--version` / `--help` would have already
// exited. If we somehow land here with empty args, force-exit is safe
// (no daemon is running).
expect(shouldForceExitAfterMain([])).toBe(true);
});
test('returns true for flag-only argv', () => {
expect(shouldForceExitAfterMain(['--help'])).toBe(true);
expect(shouldForceExitAfterMain(['-h'])).toBe(true);
expect(shouldForceExitAfterMain(['--version'])).toBe(true);
});
test('uses process.argv.slice(2) by default when called with no args', () => {
// The default is just for the cli.ts call site convenience; the test
// verifies the default works without crashing.
expect(typeof shouldForceExitAfterMain()).toBe('boolean');
});
test('substring match avoidance: `serves` is NOT `serve`', () => {
// Future-proofing against a `gbrain serves-foo` subcommand being
// misclassified as a daemon. Strict equality, not startsWith.
expect(shouldForceExitAfterMain(['serves'])).toBe(true);
expect(shouldForceExitAfterMain(['serve-cluster'])).toBe(true);
});
test('awaited long-runners exit deliberately when their handler resolves', () => {
// `jobs work`, `jobs watch --follow`, `autopilot`, and `gbrain watch`
// (#2095) all BLOCK inside their awaited handler until done — when
// main() resolves for them, the work is over and the deliberate exit is
// correct (v0.43 #2084 contract). Only commands that RETURN from main()
// while the event loop carries the daemon (`serve`) belong in
// DAEMON_COMMANDS — `watch` blocks in its stdin iteration, so piped EOF
// must flow through the flush-exit instead of hanging on lingering
// sockets.
expect(shouldForceExitAfterMain(['jobs', 'work'])).toBe(true);
expect(shouldForceExitAfterMain(['jobs', 'watch', '--follow'])).toBe(true);
expect(shouldForceExitAfterMain(['autopilot'])).toBe(true);
expect(shouldForceExitAfterMain(['watch'])).toBe(true);
expect(shouldForceExitAfterMain(['watch', '--json'])).toBe(true);
});
});