Files
gbrain/test/agent-cli.test.ts
T
bb2e88c42a v0.42.52.0 fix(reliability): autopilot dead-job storm + supervisor wedge + sync/status/minion reliability (#2194 #2227 #1994 #1737 #1738 #1950 #1984) (#2287)
* test(supervisor): pin LOCK_HELD fence-exit is never counted as a crash (#2227)

A duplicate supervisor loses the queue-scoped DB singleton lock (#1849) and
exits LOCK_HELD before spawning a worker or emitting 'started'. summarizeCrashes
counts only worker_exited, so the fence path is structurally uncountable. Pin it
so a future refactor that logs worker_exited on the fence path fails here instead
of silently re-introducing the crash-budget breaker-trip loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autopilot): per-source cycle binds FS phases to source.local_path, not global repo (#2194 #2227)

A per-source autopilot-cycle inherited the global sync.repo_path as brainDir while
stamping DB freshness for source_id — mixed scope. FS phases (sync/lint/extract)
ran against the wrong tree, so the failure-cooldown and freshness gates would
attribute work to the wrong source. Resolve the source's local_path in the handler
(reuse the archive-recheck SELECT) and bind brainDir to it; a pure-DB source gets
null (FS phases skip) instead of falling through to the global checkout. Legacy
no-source dispatch keeps the global repoPath. Prerequisite for the cooldown/split
commits (codex outside-voice #8). Resolves TODOS:634.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(supervisor): detect a live supervisor via the DB lock under split $HOME (#2227)

jobs supervisor status + doctor read the HOME-derived pidfile, so a supervisor
started under a different $HOME (keeper=/root vs ops=/data) read as 'not running'
while healthy — the false signal that drives an operator to spawn a duplicate.
Both surfaces now fall back to the queue-scoped DB singleton lock (#1849), the
HOME-independent authority, when the pidfile shows nothing. New isLockHolderLive
keys on lock freshness (ttl + heartbeat steal-grace), never process.kill, so PID
reuse can't false-positive (pid-liveness-alone-pid-reuse). Status surfaces the
holder host/pid + recorded concurrency/max-rss from the latest started event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(supervisor): degraded retry instead of permanent give-up on crash storm (#1994 #2227)

max_crashes_exceeded gave up forever, so a transient DB-pooler blip that tripped
the soft budget wedged the queue until a human restart (#2227's breaker-trips tail).
Crossing the soft budget now enters degraded mode: keep respawning with capped
exponential backoff (60s cap — a paced retry, not a hot loop) and emit a loud
crash_budget_degraded health_warn. The existing stable-run reset clears the count
once a respawn survives >5min, so a recovered DB self-heals. Permanent give-up
fires only at a much-higher hard ceiling (maxCrashes × 10), tunable/disablable via
GBRAIN_SUPERVISOR_HARD_STOP_CRASHES (0 = never). Resolves TODOS:92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autopilot): clamp fan-out to worker concurrency + doctor warning (#2194)

Fan-out resolved to 4 (Postgres) regardless of worker --concurrency, so surplus
cycles queued behind the worker and raced the stalled-sweeper. Two fixes for the
same mismatch:
- resolveEffectiveFanoutMax clamps to max(1, concurrency-1) (reserve a slot),
  gated on a LIVE DB-lock holder so a stale started-audit row can't shrink
  throughput (codex #9/D5); no live holder → unknown → unclamped base. Escape
  hatch autopilot.fanout_clamp_to_concurrency.
- doctor's autopilot_fanout_concurrency check warns when fan-out exceeds
  effective slots — the misconfig was silent before. Advisory (started-event
  concurrency), wired into both doctor surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autopilot): per-source failure cooldown — break the dead-job storm (#2194)

Only SUCCESS gated dispatch, so a source whose cycle kept failing/timing-out
re-fanned-out every 5-min tick forever (200+ dead jobs/24h). Now a failed source
backs off with bounded exponential cooldown (10→120min). Read at DISPATCH from
minion_jobs dead/failed rows (timeouts/RSS-kills dead-letter via SQL and never
run handler code, so a write-only hook would miss them) AND re-checked at CLAIM
time in the handler (codex #5: already-queued/retrying jobs). A success clears it
(codex #7); null-source rows excluded (codex #6); engine-parity via executeRaw.
Disable with autopilot.failure_cooldown_min=0. Fail-open if config/history reads
error. Surfaced via fanout_cooldown_skipped + the fanout summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autopilot): split the cycle — per-source phases + one global-maintenance job (#2194 #2227)

N per-source cycles each ran the brain-wide global phases (embed-all/orphans/
purge/…) concurrently, thrashing the same rows and taking the worker 4→10GB in
<60s → RSS-kill → orphaned stalls. Split them: per-source jobs now run only
source-scoped (+ mixed) phases and stamp last_source_cycle_at; a new
autopilot-global-maintenance job runs the global phases ONCE per window
(idempotency_key + maxWaiting:1 = structural single-flight) and stamps
autopilot.last_global_at. This is the codex-endorsed design that replaced the
rejected skip-and-stamp-fresh approach (codex #1/#2): no freshness poisoning, no
starvation — global work always runs as its own job, never marked done when it
wasn't. PHASE_SCOPE is now a runtime partition (GLOBAL ∪ NON_GLOBAL == ALL).
last_full_cycle_at still written for doctor/legacy (no longer a global gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doctor): guard nullable engine in supervisor DB-lock fallback (#2227)

Follow-up to the supervisor-visibility commit: doctor's engine binding is
BrainEngine | null, so the inspectLock fallback must guard on a non-null engine
(tsc TS2345). No behavior change — a null engine simply skips the DB-lock probe
and falls back to the pidfile reading, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doctor): categorize autopilot_fanout_concurrency check as ops (#2194)

Follow-up to the fan-out/concurrency commit: the doctor-categories drift guard
requires every check name in doctor.ts to belong to exactly one category set.
Add the new autopilot_fanout_concurrency check to OPS_CHECK_NAMES (infrastructure
liveness, alongside wedged_queue/supervisor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update KEY_FILES for the autopilot cycle split + supervisor degraded-retry (#2194 #2227)

Post-ship document-release: refresh the KEY_FILES current-state entries that
drifted — cycle.ts (GLOBAL/NON_GLOBAL phase split + last_source_cycle_at /
autopilot.last_global_at), jobs.ts (per-source local_path brainDir, claim-time
cooldown, autopilot-global-maintenance handler), supervisor.ts + child-worker
(degraded retry instead of permanent give-up; hard ceiling), db-lock.ts
(isLockHolderLive), handler-timeouts (new handler). Regenerated llms bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(minions): handleTimeouts counts the timed-out run as a spent attempt (#1737)

The per-job timeout_at dead-letter (handleTimeouts) set status='dead' without
incrementing attempts_made, unlike the wall-clock and stall dead-letter siblings.
It is the FIRST killer to fire for the long-lane handlers (subagent / embed-backfill
/ autopilot-cycle) because timeout_ms is stamped at submit, so a timed-out long job
reported `attempts: 0/N (started: N)`. Mirror the siblings with attempts_made + 1
(terminal, no retry). Safe against double-count: the worker sweep runs handleStalled
-> handleTimeouts -> handleWallClockTimeouts sequentially and awaited, each guarded on
status='active', so the first to dead-letter excludes the row from the rest.

Regression assertions added (test/minions.test.ts + e2e/minions-resilience.test.ts)
so the increment can't be silently dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent): recognize trailing switches in `agent run`, keep prompts freeform (#1738)

parseRunFlags() broke flag parsing at the first positional token, so any flag
after the prompt (`gbrain agent run "do X" --detach`) was swallowed into the
prompt string and silently ignored. Now the no-value switches --detach/--follow/
--no-follow are hoisted when they trail the prompt, while everything else stays
verbatim: an unknown --word is treated as prompt text (no "unknown flag" throw),
a --switch mid-prompt is preserved, and `--` suppresses hoisting entirely for a
literal escape. Value-flags now reject a missing or flag-shaped value (and
--max-turns/--timeout-ms a non-number) instead of capturing undefined/NaN.

Contract change: a prompt that starts with or trails an unguarded --word no
longer errors; a literal trailing --detach needs `--`. Help text updated; tests
revised + extended (test/agent-cli.test.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): honest live-sync status + progress-aware stall-abort (#1950)

Finishes the #2255 honest-freshness story for two gaps it left.

(a) `gbrain sources status` printed "idle" while a sync proc held the per-source
lock (the reported bug). New shared liveSyncStatus() helper in db-lock.ts reads
the SAME live-lock signal `gbrain doctor` uses; runStatus now shows "running"
(BACKFILL column + a sync_running field in --json) and suppresses the misleading
"never synced" warning while a sync is live. One helper, so the surfaces can't
drift (doctor/status retrofit tracked as a follow-up).

(b) A sync wedged-but-alive kept refreshing its lock heartbeat (it fires on its
own timer) and hadn't hit the wall-clock deadline, so only a manual pkill freed
it. New in-band stall watchdog keys off FORWARD IMPORT PROGRESS (progress.tick),
not the heartbeat: if no file completes for GBRAIN_SYNC_STALL_ABORT_SECONDS
(default 900s), it aborts via a controller composed into opts.signal, so the
drain returns partial() (last_commit unchanged, next run resumes from the
checkpoint) and withRefreshingLock releases the lock. Limits, documented in
code: a single file slower than the window trips it; a fully starved event loop
won't fire the timer (the wall-clock hard deadline is that backstop).

Tests: liveSyncStatus (live/expired/none/per-source) in db-lock-inspect; the
resolveStallAbortSeconds env matrix in sync-hard-deadline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(status): version field + per-section --deadline-ms budget (#1984)

`gbrain status` had no version in its JSON envelope and could hang on a slow
connection with no way to get a partial answer. Two additions:

- version: the StatusReport JSON now carries the local gbrain CLI version so a
  poller can pin behavior to a build. Thin-client also surfaces remote_version
  (the brain server's version), and the get_status_snapshot MCP op reports its
  version for that parity.
- --deadline-ms=N / --fast: a shared wall-clock budget. Each section is raced
  against the REMAINING budget via Promise.race (NOT process-watchdog, which
  SIGKILLs and can't return partial output), so one slow/hung section can't
  strand the snapshot — it's marked stale and the rest still return. The
  envelope gains partial:true + stale_sections[]; exit code stays 0 (a snapshot
  was produced). Invalid --deadline-ms → exit 2.

Tests: parseDeadlineFlag + withSectionDeadline (hermetic), the usage-error exit,
version presence in the PGLite envelope, and the op's version key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): report stall_timeout distinctly + document in-flight limit (#1950)

Pre-landing review (codex + adversarial): the stall watchdog aborted opts.signal
but the per-iteration abort checks returned partial('timeout'), collapsing a
wedge-reap into a user --timeout/SIGINT so JSON consumers couldn't tell them
apart. Add a 'stall_timeout' reason (set via a stallAborted flag) on the three
import-loop abort sites; deletes/renames-phase and checkpoint sites stay 'timeout'.
Sharpen the watchdog comment: the abort is observed BETWEEN files, so a hang
inside a single importFile is not interrupted until it returns (TODO: thread a
cancellation signal through importFile).

* fix(agent): `--` escape suppresses trailing-switch hoisting anywhere (#1738)

Pre-landing review: the leading-flag loop breaks at the first positional, so the
`escaped` flag only fired for a leading `--`. A `--` placed after a positional
left trailing-switch hoisting active, so `agent run note -- body --detach`
silently detached and dropped the `--` as junk. Suppress hoisting whenever a
literal `--` appears in the prompt. Regression test added.

* fix(status): deadline-ms usage-error + scoped stale_sections + cancel losing remote call (#1984)

Pre-landing review (codex): (1) bare `--deadline-ms` with no value silently fell
through to no-budget/--fast instead of a usage error; (2) thin-client timeout
reported both sync+cycle stale even under `--section sync`, naming a section the
caller excluded (local path was already correct); (3) the section race abandoned
the remote promise locally but didn't cancel the in-flight MCP call — pass the
budget as timeoutMs so the losing side actually cancels. Regression test added.

* v0.42.52.0 fix(reliability): autopilot dead-job storm + supervisor wedge + sync/status/minion reliability (#2194 #2227 #1994 #1737 #1738 #1950 #1984)

Bundles the already-reviewed autopilot/supervisor stabilization (#2194 #2227
#1994: cycle split, per-source failure cooldown, fan-out clamp, degraded
supervisor retry, DB-lock live-supervisor detection) with four operational
fixes: minion timeout attempt-accounting (#1737), agent-run trailing-flag
parsing (#1738), honest live-sync sources status + progress-aware stall
watchdog (#1950), and status version + --deadline-ms partial result (#1984).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document GBRAIN_SYNC_STALL_ABORT_SECONDS env knob (#1950)

Post-ship doc sync (/document-release): add the sync stall watchdog env var to
the CLAUDE.md sync-tuning table (Five → Six knobs) + regenerate the llms bundle.

* test: quarantine #2249 fanout tests as *.serial (R1 env-isolation) (#2194)

The cherry-picked autopilot-fanout-clamp + doctor-autopilot-fanout-concurrency
tests mutate process.env.GBRAIN_AUDIT_DIR in beforeEach/afterEach, which the
check:test-isolation R1 lint flags (parallel shards load multiple files per
process). Rename to *.serial.test.ts (sanctioned quarantine — they run under
--max-concurrency=1) instead of restructuring the reviewed test bodies. No logic
change; both files stay green (9 tests). Fixes the failing verify CI check.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:43 -07:00

329 lines
12 KiB
TypeScript

/**
* `gbrain agent` CLI tests. Covers arg parsing, --since parser, and the
* submit path end-to-end against PGLite so we verify trusted submission,
* protected-name guard, and fan-out wiring.
*
* The full handler-run loop is NOT exercised here (tested in subagent-
* handler.test.ts). This file checks the CLI's submission + orchestration
* glue.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { __testing as agentTesting } from '../src/commands/agent.ts';
import { parseSince } from '../src/commands/agent-logs.ts';
import { isProtectedJobName, PROTECTED_JOB_NAMES } from '../src/core/minions/protected-names.ts';
let engine: PGLiteEngine;
let queue: MinionQueue;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
queue = new MinionQueue(engine);
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM minion_jobs');
});
describe('parseRunFlags', () => {
test('follow defaults off when stdout is non-TTY (test env)', () => {
const { flags, rest } = agentTesting.parseRunFlags(['hello', 'world']);
expect(flags.follow).toBe(process.stdout.isTTY === true);
expect(rest).toEqual(['hello', 'world']);
});
test('leading flags parsed; first positional begins the prompt', () => {
const { flags, rest } = agentTesting.parseRunFlags([
'--model', 'claude-opus-4-7', '--max-turns', '30', 'summarize', 'everything',
]);
expect(flags.model).toBe('claude-opus-4-7');
expect(flags.maxTurns).toBe(30);
expect(rest).toEqual(['summarize', 'everything']);
});
test('--tools comma-split', () => {
const { flags } = agentTesting.parseRunFlags(['--tools', 'brain_search, brain_get_page', 'prompt']);
expect(flags.tools).toEqual(['brain_search', 'brain_get_page']);
});
test('--detach implies !follow', () => {
const { flags } = agentTesting.parseRunFlags(['--detach', 'x']);
expect(flags.detach).toBe(true);
expect(flags.follow).toBe(false);
});
test('double-dash ends flag parsing explicitly', () => {
const { flags, rest } = agentTesting.parseRunFlags(['--model', 'm', '--', '--not-a-flag']);
expect(flags.model).toBe('m');
expect(rest).toEqual(['--not-a-flag']);
});
test('#1738: unknown --flag is prompt text, not an error', () => {
const { rest } = agentTesting.parseRunFlags(['--what', 'x']);
expect(rest).toEqual(['--what', 'x']);
});
test('#1738: trailing --detach is hoisted out of the prompt', () => {
const { flags, rest } = agentTesting.parseRunFlags(['do', 'the', 'thing', '--detach']);
expect(flags.detach).toBe(true);
expect(flags.follow).toBe(false);
expect(rest).toEqual(['do', 'the', 'thing']);
});
test('#1738: leading flags + trailing switch both apply', () => {
const { flags, rest } = agentTesting.parseRunFlags(['--model', 'm', 'summarize', '--detach', '--no-follow']);
expect(flags.model).toBe('m');
expect(flags.detach).toBe(true);
expect(flags.follow).toBe(false);
expect(rest).toEqual(['summarize']);
});
test('#1738: a --switch mid-prompt (not trailing) stays verbatim', () => {
const { flags, rest } = agentTesting.parseRunFlags(['summarize', '--detach', 'the', 'doc']);
expect(flags.detach).toBe(false);
expect(rest).toEqual(['summarize', '--detach', 'the', 'doc']);
});
test('#1738: a freeform prompt starting with --word is preserved', () => {
const { rest } = agentTesting.parseRunFlags(['--note:', 'do', 'the', 'thing']);
expect(rest).toEqual(['--note:', 'do', 'the', 'thing']);
});
test('#1738: -- suppresses trailing-switch hoisting', () => {
const { flags, rest } = agentTesting.parseRunFlags(['--', 'do', 'x', '--detach']);
expect(flags.detach).toBe(false);
expect(rest).toEqual(['do', 'x', '--detach']);
});
test('#1738: -- AFTER a positional also suppresses hoisting (no silent detach flip)', () => {
// The leading-flag loop breaks at the first positional, so the `escaped`
// flag never fires for a `--` placed later. A literal `--` ANYWHERE must
// still mean "hoist nothing" — otherwise `agent run note -- body --detach`
// silently detaches and drops the `--` as junk.
const { flags, rest } = agentTesting.parseRunFlags(['note', '--', 'body', '--detach']);
expect(flags.detach).toBe(false);
expect(rest).toEqual(['note', '--', 'body', '--detach']);
});
test('#1738: value-flag missing its value throws a usage error', () => {
expect(() => agentTesting.parseRunFlags(['--model'])).toThrow(/requires a value/);
expect(() => agentTesting.parseRunFlags(['--model', '--detach', 'x'])).toThrow(/requires a value/);
});
test('#1738: numeric value-flag rejects a non-number', () => {
expect(() => agentTesting.parseRunFlags(['--max-turns', 'abc', 'x'])).toThrow(/expects a number/);
});
test('--subagent-def + --timeout-ms parsed', () => {
const { flags } = agentTesting.parseRunFlags([
'--subagent-def', 'researcher', '--timeout-ms', '60000', 'hello',
]);
expect(flags.subagentDef).toBe('researcher');
expect(flags.timeoutMs).toBe(60000);
});
test('--fanout-manifest parsed', () => {
const { flags } = agentTesting.parseRunFlags(['--fanout-manifest', '/tmp/m.json']);
expect(flags.fanoutManifest).toBe('/tmp/m.json');
});
});
describe('parseSince', () => {
test('returns undefined on empty input', () => {
expect(parseSince(undefined)).toBeUndefined();
expect(parseSince('')).toBeUndefined();
});
test('parses ISO-8601 timestamps', () => {
const iso = '2026-04-20T12:00:00.000Z';
expect(parseSince(iso)).toBe(iso);
});
test('parses relative 5m', () => {
const out = parseSince('5m')!;
const parsed = new Date(out).getTime();
const now = Date.now();
expect(now - parsed).toBeGreaterThanOrEqual(5 * 60 * 1000 - 1000);
expect(now - parsed).toBeLessThan(5 * 60 * 1000 + 1000);
});
test('parses relative 2h', () => {
const out = parseSince('2h')!;
const delta = Date.now() - new Date(out).getTime();
expect(delta).toBeGreaterThanOrEqual(2 * 3600 * 1000 - 1000);
});
test('parses relative 1d', () => {
const out = parseSince('1d')!;
const delta = Date.now() - new Date(out).getTime();
expect(delta).toBeGreaterThanOrEqual(86_400_000 - 1000);
});
test('throws on unparseable input', () => {
expect(() => parseSince('not-a-date')).toThrow(/could not parse/);
});
});
describe('protected-name guard includes subagent + aggregator', () => {
test('shell stays protected', () => {
expect(isProtectedJobName('shell')).toBe(true);
expect(PROTECTED_JOB_NAMES.has('shell')).toBe(true);
});
test('subagent is protected (v0.15)', () => {
expect(isProtectedJobName('subagent')).toBe(true);
});
test('subagent_aggregator is protected (v0.15)', () => {
expect(isProtectedJobName('subagent_aggregator')).toBe(true);
});
test('a random non-protected name is not protected', () => {
expect(isProtectedJobName('sync')).toBe(false);
});
test('trim normalization still blocks " subagent "', () => {
expect(isProtectedJobName(' subagent ')).toBe(true);
});
});
describe('queue.add trusted-submit gate for subagent', () => {
test('subagent without allowProtectedSubmit throws', async () => {
await expect(queue.add('subagent', { prompt: 'hi' })).rejects.toThrow();
});
test('subagent with allowProtectedSubmit succeeds', async () => {
const job = await queue.add('subagent', { prompt: 'hi' }, {}, { allowProtectedSubmit: true });
expect(job.name).toBe('subagent');
expect(job.status).toBe('waiting');
});
test('subagent_aggregator gated the same way', async () => {
await expect(queue.add('subagent_aggregator', { children_ids: [] })).rejects.toThrow();
const ok = await queue.add('subagent_aggregator', { children_ids: [1] }, {}, {
allowProtectedSubmit: true,
});
expect(ok.name).toBe('subagent_aggregator');
});
test('v0.38 S1.7: subagent with any tool-supporting provider passes the queue gate', async () => {
// v0.38 D6/D7 — the Anthropic pin is removed. The gateway tool loop
// routes any provider with native tool calling. Submit-time guard now
// refuses ONLY on unusable:no_tools or unknown verdicts.
const openaiJob = await queue.add(
'subagent',
{ prompt: 'hi', model: 'openai:gpt-5.2' },
{},
{ allowProtectedSubmit: true },
);
expect(openaiJob.name).toBe('subagent');
const googleJob = await queue.add(
'subagent',
{ prompt: 'hi', model: 'google:gemini-1.5-pro' },
{},
{ allowProtectedSubmit: true },
);
expect(googleJob.name).toBe('subagent');
});
test('v0.38 S1.7: subagent with Anthropic data.model still succeeds', async () => {
const job = await queue.add(
'subagent',
{ prompt: 'hi', model: 'anthropic:claude-opus-4-7' },
{},
{ allowProtectedSubmit: true },
);
expect(job.name).toBe('subagent');
});
test('v0.38 S1.7: subagent with unknown provider is rejected at submit time', async () => {
// The remaining hard reject — unknown providers can't be classified, so
// we refuse the job rather than risk burning money on something we
// can't verify supports tools.
await expect(
queue.add('subagent', { prompt: 'hi', model: 'madeup-provider:foo' }, {}, { allowProtectedSubmit: true }),
).rejects.toThrow(/unknown provider/i);
});
test('v0.38 S1.7: subagent with embedding-only provider (no chat) is rejected', async () => {
// Voyage has no chat touchpoint → classifyCapabilities returns 'unknown' →
// refused at submit. Same rejection path as unknown provider.
await expect(
queue.add('subagent', { prompt: 'hi', model: 'voyage:voyage-3-large' }, {}, { allowProtectedSubmit: true }),
).rejects.toThrow(/unknown provider/i);
});
});
describe('fan-out manifest shape (integration)', () => {
test('fanout-manifest with 3 entries creates 3 subagent children + 1 aggregator', async () => {
// Manually replicate what runAgentRun does for --fanout-manifest > 1.
// We don't invoke runAgentRun (it calls process.exit on error) — we
// assert that the plumbing works via direct queue calls with the
// same flags it uses.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fanout-'));
try {
const manifestPath = path.join(tmp, 'm.json');
fs.writeFileSync(manifestPath, JSON.stringify([
{ prompt: 'chunk 1' }, { prompt: 'chunk 2' }, { prompt: 'chunk 3' },
]));
// Aggregator first.
const agg = await queue.add(
'subagent_aggregator',
{ children_ids: [] },
{ max_stalled: 3 },
{ allowProtectedSubmit: true },
);
const kids: number[] = [];
for (const p of ['chunk 1', 'chunk 2', 'chunk 3']) {
const c = await queue.add(
'subagent',
{ prompt: p },
{ parent_job_id: agg.id, on_child_fail: 'continue', max_stalled: 3 },
{ allowProtectedSubmit: true },
);
kids.push(c.id);
}
await engine.executeRaw(
`UPDATE minion_jobs SET data = jsonb_set(data, '{children_ids}', $1::jsonb) WHERE id = $2`,
[JSON.stringify(kids), agg.id],
);
// Aggregator should be in waiting-children since kids were submitted
// with parent_job_id = agg.id (Lane 1B behavior).
const aggNow = await queue.getJob(agg.id);
expect(aggNow?.status).toBe('waiting-children');
// Aggregator's data.children_ids reflects the spawned children.
const dataRow = await engine.executeRaw<{ data: unknown }>(
`SELECT data FROM minion_jobs WHERE id = $1`, [agg.id],
);
const data = typeof dataRow[0]!.data === 'string'
? JSON.parse(dataRow[0]!.data as string)
: dataRow[0]!.data as Record<string, unknown>;
expect(data.children_ids).toEqual(kids);
// Each child should have on_child_fail = 'continue'.
const childRows = await engine.executeRaw<{ on_child_fail: string }>(
`SELECT on_child_fail FROM minion_jobs WHERE parent_job_id = $1`, [agg.id],
);
expect(childRows.length).toBe(3);
expect(childRows.every(r => r.on_child_fail === 'continue')).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});