mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): op_checkpoints pin write double-encodes jsonb — every sync aborts (#2339) recordCompleted bound JSON.stringify(array) to a $3::jsonb param via postgres.js .unsafe(), double-encoding it into a jsonb string scalar that violates the v119 op_checkpoints_completed_keys_array CHECK — aborting every multi-source sync on real Postgres at the first checkpoint write. PGLite parses the string silently, which is why unit tests stayed green and it shipped. Cast through $3::text::jsonb so the text->jsonb cast parses a genuine array. Adds a DATABASE_URL-gated parity test + a dedicated Postgres CI job so the guard can never silently skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db): sweep positional jsonb double-encode sites + AST CI guard (#2324) Every executeRaw/.unsafe site that bound JSON.stringify(x) to a bare positional jsonb cast double-encodes on real Postgres (same class as #2339). Sweep them all to the text::jsonb form across query-cache, sources-ops, llm-base, calibration-profile, impact-capture, subagent, receipt-write, traversal-cache, symbol-resolver, and the agent/sources commands. Adds scripts/check-jsonb-params.mjs (AST-lite scanner for the positional form the legacy template grep misses, incl. generic-typed calls), wired into check-jsonb-pattern.sh, with a self-test. PGLite's native db.query is not scanned — it parses text to jsonb natively, so the bug can't occur there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(search,eval): alias-hop injected results carry page_id (contradiction-probe crash) applyAliasHop injected synthetic SearchResults without page_id (the `as SearchResult` cast hid the missing field), so listActiveTakesForPages bound undefined/NaN into ANY($1::int[]) and crashed the whole contradiction probe on real Postgres. Stamp page_id=page.id at the injection site and add a finite-id filter in generateIntraPagePairs as a defensive backstop (mirrors hybrid.ts:63). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(engines): positional jsonb binding rule (text::jsonb vs the double-encode trap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard Bumps VERSION + package.json to 0.42.53.0, adds the CHANGELOG entry, and regenerates llms-full.txt. Ships the #2339 sync-abort hotfix, the repo-wide positional jsonb double-encode sweep, the alias-hop contradiction-probe crash fix, and the new positional-form CI guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: post-ship sync — jsonb invariant now covers the positional form + new guard CLAUDE.md JSONB invariant + KEY_FILES (sql-query, check-jsonb-pattern, op-checkpoint) now describe the #2339 positional double-encode class, the $N::text::jsonb fix, and the new check-jsonb-params.mjs guard. Regenerates llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/**
|
|
* `gbrain agent` CLI: the user-facing entry point for the v0.15 subagent
|
|
* runtime.
|
|
*
|
|
* gbrain agent run <prompt> [flags]
|
|
* gbrain agent logs <job_id> [--follow] [--since <spec>]
|
|
*
|
|
* `run` submits a subagent job (or fan-out of N subagents + aggregator)
|
|
* under the trusted-submit flag so the PROTECTED_JOB_NAMES guard doesn't
|
|
* reject. It does NOT execute the loop here — the handler runs in a
|
|
* `gbrain jobs work` process. `--follow` tails status until terminal;
|
|
* without `--follow` (or with `--detach`) the CLI prints the job id and
|
|
* exits, leaving the user to check back with `gbrain agent logs`.
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import { MinionQueue } from '../core/minions/queue.ts';
|
|
import { waitForCompletion, TimeoutError } from '../core/minions/wait-for-completion.ts';
|
|
import type { MinionJobInput, SubagentHandlerData, AggregatorHandlerData } from '../core/minions/types.ts';
|
|
import { runAgentLogs } from './agent-logs.ts';
|
|
|
|
// ── arg parsing helpers ────────────────────────────────────
|
|
|
|
function parseFlag(args: string[], flag: string): string | undefined {
|
|
const idx = args.indexOf(flag);
|
|
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
|
|
}
|
|
function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); }
|
|
|
|
/** Keep CLI args that look like flags from being eaten as the prompt. */
|
|
function isKnownFlag(s: string): boolean {
|
|
return s.startsWith('--');
|
|
}
|
|
|
|
// ── command dispatcher ────────────────────────────────────
|
|
|
|
export async function runAgent(engine: BrainEngine, args: string[]): Promise<void> {
|
|
const sub = args[0];
|
|
if (!sub || sub === '--help' || sub === '-h') {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
switch (sub) {
|
|
case 'run':
|
|
await runAgentRun(engine, args.slice(1));
|
|
return;
|
|
case 'logs':
|
|
await runAgentLogsCmd(engine, args.slice(1));
|
|
return;
|
|
default:
|
|
console.error(`gbrain agent: unknown subcommand "${sub}"`);
|
|
printHelp();
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
function printHelp(): void {
|
|
console.log(`gbrain agent — durable LLM agent runs (v0.15)
|
|
|
|
USAGE
|
|
gbrain agent run <prompt> [flags]
|
|
gbrain agent logs <job_id> [--follow] [--since <spec>]
|
|
|
|
SUBMITTING
|
|
gbrain agent run <prompt>
|
|
--subagent-def <name> Named plugin subagent (from GBRAIN_PLUGIN_PATH)
|
|
--model <id> Anthropic model id (defaults to sonnet)
|
|
--max-turns <n> Max assistant turns (default 20)
|
|
--tools a,b,c Subset of registered tool names (comma list)
|
|
--timeout-ms <n> Per-job wall-clock timeout
|
|
--fanout-manifest <path> JSON array of {prompt, input_vars?} — one child each
|
|
--follow Tail status until terminal (default on TTY)
|
|
--detach Submit + print job id, exit immediately
|
|
|
|
Flags before the prompt are parsed normally. The no-value switches
|
|
--detach, --follow and --no-follow are ALSO recognized when they trail
|
|
the prompt, so \`gbrain agent run "do X" --detach\` detaches. Any other
|
|
--word is treated as prompt text (no error). Use \`--\` to end flag
|
|
parsing and pass the rest verbatim:
|
|
gbrain agent run -- "literally --detach this, with --flags"
|
|
|
|
VIEWING
|
|
gbrain agent logs <job_id>
|
|
--follow Keep polling until the job reaches terminal
|
|
--since <spec> ISO-8601 timestamp OR relative ("5m","1h","2d")
|
|
|
|
NOTES
|
|
Submitting subagent jobs is trusted-only; MCP submitters receive
|
|
permission_denied. The worker needs ANTHROPIC_API_KEY set, or the
|
|
first LLM turn of a claimed job fails.
|
|
`);
|
|
}
|
|
|
|
// ── `gbrain agent run` ────────────────────────────────────
|
|
|
|
interface RunFlags {
|
|
subagentDef?: string;
|
|
model?: string;
|
|
maxTurns?: number;
|
|
tools?: string[];
|
|
timeoutMs?: number;
|
|
fanoutManifest?: string;
|
|
follow: boolean;
|
|
detach: boolean;
|
|
}
|
|
|
|
/** No-value switches that may also trail the prompt and get hoisted out (#1738). */
|
|
const BOOLEAN_TAIL_FLAGS = new Set(['--follow', '--no-follow', '--detach']);
|
|
|
|
function applyBooleanFlag(flags: RunFlags, a: string): void {
|
|
if (a === '--follow') flags.follow = true;
|
|
else if (a === '--no-follow') flags.follow = false;
|
|
else { flags.detach = true; flags.follow = false; } // --detach
|
|
}
|
|
|
|
/** Read the value for a value-flag, rejecting a missing or flag-shaped value. */
|
|
function requireFlagValue(args: string[], i: number, flag: string): string {
|
|
const v = args[i];
|
|
if (v === undefined || v.startsWith('--')) {
|
|
throw new Error(`gbrain agent run: ${flag} requires a value. Run \`gbrain agent run --help\`.`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function parseIntFlagValue(v: string, flag: string): number {
|
|
const n = parseInt(v, 10);
|
|
if (Number.isNaN(n)) {
|
|
throw new Error(`gbrain agent run: ${flag} expects a number, got "${v}".`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Parse `agent run` args into flags + prompt (#1738).
|
|
*
|
|
* args ──► [ leading flag zone ] [ ── ? ] [ prompt … (trailing booleans) ]
|
|
*
|
|
* Leading zone: known flags (value + boolean) are consumed left-to-right until
|
|
* the first positional token, an UNKNOWN --flag, or an explicit `--`. An
|
|
* unknown --flag is NOT an error — it begins the freeform prompt, so
|
|
* `agent run "--note: do X"` works without `--`. Value-flags missing their
|
|
* value throw a usage error instead of silently capturing `undefined`/`NaN`.
|
|
*
|
|
* Prompt zone: a trailing run of the no-value switches (--detach/--follow/
|
|
* --no-follow) is hoisted out so `agent run "do X" --detach` detaches. Only
|
|
* trailing switches are hoisted; a `--word` elsewhere in the prompt stays
|
|
* verbatim. After an explicit `--`, nothing is hoisted.
|
|
*/
|
|
function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
|
|
const flags: RunFlags = {
|
|
follow: process.stdout.isTTY === true,
|
|
detach: false,
|
|
};
|
|
let i = 0;
|
|
let escaped = false;
|
|
for (; i < args.length; i++) {
|
|
const a = args[i]!;
|
|
if (a === '--') { i++; escaped = true; break; }
|
|
if (!a.startsWith('--')) break;
|
|
let known = true;
|
|
switch (a) {
|
|
case '--subagent-def': flags.subagentDef = requireFlagValue(args, ++i, a); break;
|
|
case '--model': flags.model = requireFlagValue(args, ++i, a); break;
|
|
case '--max-turns': flags.maxTurns = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
|
|
case '--tools': flags.tools = requireFlagValue(args, ++i, a).split(',').map(s => s.trim()).filter(Boolean); break;
|
|
case '--timeout-ms': flags.timeoutMs = parseIntFlagValue(requireFlagValue(args, ++i, a), a); break;
|
|
case '--fanout-manifest': flags.fanoutManifest = requireFlagValue(args, ++i, a); break;
|
|
case '--follow': flags.follow = true; break;
|
|
case '--no-follow': flags.follow = false; break;
|
|
case '--detach': flags.detach = true; flags.follow = false; break;
|
|
default: known = false; break;
|
|
}
|
|
if (!known) break; // unknown --flag → first token of the (freeform) prompt
|
|
}
|
|
const rest = args.slice(i);
|
|
// An explicit `--` terminates flag parsing wherever it appears — leading
|
|
// zone (escaped) OR after a positional (the leading loop breaks before it,
|
|
// so `escaped` stays false). Honor both: when the prompt carries a literal
|
|
// `--`, hoist nothing, so `agent run note -- --detach` keeps `--detach`
|
|
// verbatim instead of silently flipping detach mode.
|
|
if (!escaped && !rest.includes('--')) {
|
|
while (rest.length > 0 && BOOLEAN_TAIL_FLAGS.has(rest[rest.length - 1]!)) {
|
|
applyBooleanFlag(flags, rest.pop()!);
|
|
}
|
|
}
|
|
return { flags, rest };
|
|
}
|
|
|
|
export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<void> {
|
|
const { flags, rest } = parseRunFlags(args);
|
|
const queue = new MinionQueue(engine);
|
|
|
|
// Fan-out path: --fanout-manifest supplies explicit child inputs. The
|
|
// aggregator submits first (so its id is available as parent for each
|
|
// child); children submit with on_child_fail='continue' so mixed
|
|
// outcomes don't cascade; aggregator waits in waiting-children until
|
|
// Lane 1B's terminal-set check unblocks it.
|
|
if (flags.fanoutManifest) {
|
|
await runFanout(engine, queue, flags, rest.join(' '));
|
|
return;
|
|
}
|
|
|
|
const prompt = rest.join(' ').trim();
|
|
if (!prompt) {
|
|
console.error('gbrain agent run: prompt is required');
|
|
process.exit(2);
|
|
}
|
|
|
|
const data: SubagentHandlerData = { prompt };
|
|
if (flags.subagentDef) data.subagent_def = flags.subagentDef;
|
|
if (flags.model) data.model = flags.model;
|
|
if (flags.maxTurns) data.max_turns = flags.maxTurns;
|
|
if (flags.tools && flags.tools.length > 0) data.allowed_tools = flags.tools;
|
|
|
|
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
|
|
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
|
|
|
|
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
|
|
allowProtectedSubmit: true,
|
|
});
|
|
|
|
process.stderr.write(`submitted: job ${job.id} (subagent)\n`);
|
|
|
|
if (flags.detach || !flags.follow) {
|
|
process.stdout.write(String(job.id) + '\n');
|
|
return;
|
|
}
|
|
|
|
await followJob(engine, queue, job.id, flags.timeoutMs);
|
|
}
|
|
|
|
// ── fan-out ───────────────────────────────────────────────
|
|
|
|
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string): Promise<void> {
|
|
const manifestPath = flags.fanoutManifest!;
|
|
let manifest: Array<{ prompt?: string; input_vars?: Record<string, unknown> }>;
|
|
try {
|
|
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) throw new Error('manifest must be a JSON array');
|
|
manifest = parsed as typeof manifest;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error(`gbrain agent run: invalid --fanout-manifest ${manifestPath}: ${msg}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
if (manifest.length === 0) {
|
|
console.error('gbrain agent run: --fanout-manifest is empty; nothing to run');
|
|
process.exit(2);
|
|
}
|
|
|
|
// Short-circuit: 1 entry → single subagent, no aggregator.
|
|
if (manifest.length === 1) {
|
|
const entry = manifest[0]!;
|
|
const data: SubagentHandlerData = {
|
|
prompt: entry.prompt ?? promptTemplate,
|
|
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
|
|
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
|
|
...(flags.model ? { model: flags.model } : {}),
|
|
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
|
|
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
|
|
};
|
|
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
|
|
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
|
|
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
|
|
allowProtectedSubmit: true,
|
|
});
|
|
process.stderr.write(`submitted: job ${job.id} (single-entry manifest short-circuit)\n`);
|
|
if (flags.detach || !flags.follow) { process.stdout.write(`${job.id}\n`); return; }
|
|
await followJob(engine, queue, job.id, flags.timeoutMs);
|
|
return;
|
|
}
|
|
|
|
// N-entry fan-out: aggregator first (so we have its id as parent), then
|
|
// N children, then flip the aggregator's children_ids to include them.
|
|
const aggregatorSeed: AggregatorHandlerData = { children_ids: [] };
|
|
const aggregator = await queue.add(
|
|
'subagent_aggregator',
|
|
aggregatorSeed as unknown as Record<string, unknown>,
|
|
{ max_stalled: 3 },
|
|
{ allowProtectedSubmit: true },
|
|
);
|
|
|
|
const childIds: number[] = [];
|
|
for (const entry of manifest) {
|
|
const data: SubagentHandlerData = {
|
|
prompt: entry.prompt ?? promptTemplate,
|
|
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
|
|
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
|
|
...(flags.model ? { model: flags.model } : {}),
|
|
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
|
|
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
|
|
};
|
|
const submitOpts: Partial<MinionJobInput> = {
|
|
parent_job_id: aggregator.id,
|
|
on_child_fail: 'continue', // mixed-outcome aggregation
|
|
max_stalled: 3,
|
|
};
|
|
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
|
|
const child = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
|
|
allowProtectedSubmit: true,
|
|
});
|
|
childIds.push(child.id);
|
|
}
|
|
|
|
// Update the aggregator's data with the final children_ids. We have to
|
|
// do this after submission because each add() returns the committed
|
|
// row's id; the aggregator's seed started with an empty array.
|
|
await engine.executeRaw(
|
|
`UPDATE minion_jobs SET data = jsonb_set(data, '{children_ids}', $1::text::jsonb) WHERE id = $2`,
|
|
[JSON.stringify(childIds), aggregator.id],
|
|
);
|
|
|
|
process.stderr.write(
|
|
`submitted: aggregator job ${aggregator.id} + ${childIds.length} subagent children ` +
|
|
`(${childIds[0]}..${childIds[childIds.length - 1]})\n`,
|
|
);
|
|
|
|
if (flags.detach || !flags.follow) {
|
|
process.stdout.write(`${aggregator.id}\n`);
|
|
return;
|
|
}
|
|
await followJob(engine, queue, aggregator.id, flags.timeoutMs);
|
|
}
|
|
|
|
// ── follow ────────────────────────────────────────────────
|
|
|
|
async function followJob(engine: BrainEngine, queue: MinionQueue, jobId: number, timeoutMs?: number): Promise<void> {
|
|
process.stderr.write(`[gbrain agent] following job ${jobId} (Ctrl-C to detach)...\n`);
|
|
const ac = new AbortController();
|
|
const onSigint = () => ac.abort();
|
|
process.once('SIGINT', onSigint);
|
|
try {
|
|
// Streaming logs happen in the background; we poll the terminal state
|
|
// in parallel so the function returns as soon as the job completes.
|
|
const logsP = runAgentLogs(engine, jobId, { follow: true, signal: ac.signal, pollMs: 1000 });
|
|
try {
|
|
const job = await waitForCompletion(queue, jobId, {
|
|
timeoutMs: timeoutMs ?? 24 * 60 * 60 * 1000,
|
|
pollMs: 1000,
|
|
signal: ac.signal,
|
|
});
|
|
ac.abort();
|
|
await logsP.catch(() => {});
|
|
process.stderr.write(`[gbrain agent] job ${jobId} terminal: ${job.status}\n`);
|
|
if (job.result != null) process.stdout.write(JSON.stringify(job.result, null, 2) + '\n');
|
|
if (job.status !== 'completed') process.exit(1);
|
|
} catch (e) {
|
|
if (e instanceof TimeoutError) {
|
|
process.stderr.write(`[gbrain agent] timeout after ${e.elapsedMs}ms — job is still running. Check with: gbrain jobs get ${jobId}\n`);
|
|
process.exit(3);
|
|
}
|
|
throw e;
|
|
}
|
|
} finally {
|
|
process.removeListener('SIGINT', onSigint);
|
|
}
|
|
}
|
|
|
|
// ── `gbrain agent logs` ────────────────────────────────────
|
|
|
|
async function runAgentLogsCmd(engine: BrainEngine, args: string[]): Promise<void> {
|
|
const jobIdStr = args.find(a => !isKnownFlag(a));
|
|
if (!jobIdStr) {
|
|
console.error('gbrain agent logs: <job_id> is required');
|
|
process.exit(2);
|
|
}
|
|
const jobId = parseInt(jobIdStr, 10);
|
|
if (!Number.isFinite(jobId) || jobId <= 0) {
|
|
console.error(`gbrain agent logs: "${jobIdStr}" is not a valid job id`);
|
|
process.exit(2);
|
|
}
|
|
const follow = hasFlag(args, '--follow');
|
|
const since = parseFlag(args, '--since');
|
|
|
|
const ac = new AbortController();
|
|
const onSigint = () => ac.abort();
|
|
process.once('SIGINT', onSigint);
|
|
try {
|
|
await runAgentLogs(engine, jobId, { follow, since, signal: ac.signal });
|
|
} finally {
|
|
process.removeListener('SIGINT', onSigint);
|
|
}
|
|
}
|
|
|
|
// Expose for tests.
|
|
export const __testing = {
|
|
parseRunFlags,
|
|
};
|