Files
gbrain/test/e2e/subagent-gateway-path.test.ts
T
23e0541d9b fix(cycle): budget the patterns subagent from remaining job time — one phase's fixed 35-min worst case defeats any interval-derived cycle budget (#2781) (#2959)
* fix(cycle): budget the patterns subagent from remaining job time, not a fixed constant (#2781)

The autopilot-cycle job gets an interval-derived timeout stamped at submit,
but the patterns phase submits its subagent with a fixed 30-min job timeout
and waits up to 35 min — one phase's worst case exceeds ANY interval-derived
budget <= 35 min, so the parent job dead-letters mid-patterns and the tail
phases (consolidate -> schema-suggest) starve for days (#2781, the deeper
half left open by the #2852 dispatch-floor fix).

- MinionJobContext.deadlineAtMs: absolute deadline from the claim-time
  timeout_at stamp (the DB ground truth handleTimeouts() sweeps against;
  re-stamped on every claim so retries get a fresh budget). Null when the
  job has no per-job timeout.
- worker: the per-job abort timer now derives its delay from timeout_at
  when present, so the in-process timer, the DB sweeper, and the
  handler-visible deadline agree on ONE absolute instant.
- autopilot-cycle + autopilot-global-maintenance handlers thread
  deadlineAtMs into runCycle; CycleOpts carries it to the patterns phase.
- patterns: clampSubagentBudgets() derives BOTH the child job timeout and
  the wait timeout from the same child deadline (parent deadline minus a
  60s stop-margin reserve — enough for the wait poll + force-evict grace
  + cleanup, deliberately NOT a promise that tail phases complete). Under
  a 2-min minimum the phase skips honestly (insufficient_cycle_budget)
  instead of submitting a guaranteed-kill LLM call; the next cycle
  retries with a fresh budget.
- Direct callers (gbrain dream) pass no deadline and keep the configured
  timeouts unchanged.

Follow-up (separate PR): synthesize has the same shape plus sequential
per-child waits that accumulate N x subagent_wait_timeout_ms past any
parent budget; it needs per-wait remaining-time recomputation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF

* fix(cycle): address review — cancel timed-out patterns child; thread deadline through phase-wrapper handlers

- P1: the child's timeout_ms clock starts at ITS claim, so a queued child
  could outlive the parent deadline the wait was clamped to. On wait
  timeout, cancelJob strips it (waiting -> cancelled; active -> lock
  stripped, worker abort fires next renew tick).
- P2: makePhaseHandler (standalone patterns/synthesize/... minion jobs)
  now threads job.deadlineAtMs into runCycle like the autopilot handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:56:00 -07:00

428 lines
16 KiB
TypeScript

/**
* E2E: runSubagentViaGateway integration path (v0.38 Slice 1 + S1.5).
*
* Exercises the FULL gateway-native subagent handler path end-to-end:
* - Handler entry → reads `agent.use_gateway_loop` config → routes to gateway path
* - runSubagentViaGateway builds ChatToolDef[] + ToolHandler Map from ToolDef
* - Calls gateway.toolLoop() with persistence callbacks
* - Callbacks write subagent_messages (v2 ChatBlock shape) +
* subagent_tool_executions (with ordinal + gbrain_tool_use_id) under
* the write-ordering invariant
* - Returns SubagentResult mapped from gateway loop result
*
* Hermetic: PGLite in-memory engine, gateway transport stubbed via
* `__setChatTransportForTests`. No ANTHROPIC_API_KEY, no real Anthropic
* SDK instantiation (we stub `makeAnthropic` so the legacy-path fallback
* doesn't trip on missing env).
*
* Plan reference: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
* (Slice 1 verification step 6 + cross-provider crash-replay regression — the
* load-bearing test the CEO/codex review called out before v0.38 ships).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { makeSubagentHandler } from '../../src/core/minions/handlers/subagent.ts';
import type { MinionJobContext, ToolDef, ToolCtx } from '../../src/core/minions/types.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
type ChatBlock,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
// ── Helpers ─────────────────────────────────────────────────
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', '85');
await engine.setConfig('agent.use_gateway_loop', 'true');
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
expansion_model: 'anthropic:claude-haiku-4-5',
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
});
});
function clearGateway(): void {
__setChatTransportForTests(null);
resetGateway();
}
interface FakeJobOpts {
prompt: string;
model?: string;
allowed_tools?: string[];
}
async function makeFakeJob(opts: FakeJobOpts): Promise<{ jobId: number; ctx: MinionJobContext; tokenSink: any[] }> {
// Insert a minion_jobs row so foreign keys validate (subagent_tool_executions.job_id FK).
const rows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())
RETURNING id`,
[JSON.stringify({ prompt: opts.prompt, model: opts.model, allowed_tools: opts.allowed_tools })],
);
const jobId = rows[0].id;
const tokenSink: Array<{ input?: number; output?: number; cache_read?: number }> = [];
const abortCtrl = new AbortController();
const shutdownCtrl = new AbortController();
const ctx: MinionJobContext = {
id: jobId,
name: 'subagent',
data: { prompt: opts.prompt, model: opts.model, allowed_tools: opts.allowed_tools },
attempts_made: 0,
signal: abortCtrl.signal,
deadlineAtMs: null,
shutdownSignal: shutdownCtrl.signal,
updateProgress: async () => {},
updateTokens: async (t) => { tokenSink.push(t); },
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
return { jobId, ctx, tokenSink };
}
/**
* Stub ToolDef registry — avoids pulling in buildBrainTools (which needs
* config + engine + brain setup). Tools are simple in-test functions.
*/
function makeStubTools(executions: Array<{ name: string; input: unknown; ts: number }>): ToolDef[] {
return [
{
name: 'search',
description: 'stub search',
input_schema: { type: 'object' },
idempotent: true,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'search', input, ts: Date.now() });
return { results: [{ slug: 'wiki/foo' }] };
},
},
{
name: 'put_page',
description: 'stub put_page (non-idempotent)',
input_schema: { type: 'object' },
idempotent: false,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'put_page', input, ts: Date.now() });
return { saved: true };
},
},
{
name: 'always_fail',
description: 'stub that always throws',
input_schema: { type: 'object' },
idempotent: true,
async execute(_input: unknown, _ctx: ToolCtx) {
throw new Error('intentional tool failure');
},
},
];
}
/**
* Build the handler with a stubbed Anthropic constructor so the legacy
* code path's `new Anthropic()` at construction never fires (we route
* through the gateway path; the legacy client is unused).
*/
function buildHandler(toolRegistry: ToolDef[]) {
return makeSubagentHandler({
engine,
config: {} as any,
toolRegistry,
makeAnthropic: () => ({ messages: { create: async () => { throw new Error('legacy path should not be invoked'); } } }) as any,
});
}
// ── Tests ───────────────────────────────────────────────────
describe('runSubagentViaGateway (v0.38 Slice 1 — full handler path through gateway.toolLoop)', () => {
afterAll(() => clearGateway());
it('happy path 1-turn: gateway returns text, handler returns SubagentResult', async () => {
__setChatTransportForTests(async () => ({
text: 'all done',
blocks: [{ type: 'text', text: 'all done' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 12, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'hello', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.result).toBe('all done');
expect(result.stop_reason).toBe('end_turn');
expect(result.tokens.in).toBeGreaterThanOrEqual(12);
expect(result.tokens.out).toBeGreaterThanOrEqual(3);
expect(executions.length).toBe(0); // no tools called
// Verify persistence: 1 seed user message + 1 assistant message.
const messages = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`,
[jobId],
);
expect(messages.length).toBe(2);
expect(messages[0].role).toBe('user');
expect(messages[0].message_idx).toBe(0);
expect(messages[1].role).toBe('assistant');
expect(messages[1].message_idx).toBe(1);
});
it('happy path 2-turn with tool: dispatches, persists v2 stable ID, returns final text', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'provider-tc-1', toolName: 'search', input: { q: 'acme' } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 20, output_tokens: 8, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'found acme corp',
blocks: [{ type: 'text', text: 'found acme corp' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 25, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({
prompt: 'find acme',
model: 'anthropic:claude-sonnet-4-6',
allowed_tools: ['search'],
});
const result = await handler(ctx);
expect(result.result).toBe('found acme corp');
expect(result.stop_reason).toBe('end_turn');
expect(executions.length).toBe(1);
expect(executions[0].name).toBe('search');
expect(executions[0].input).toEqual({ q: 'acme' });
// Verify v2 stable-ID persistence: ordinal + gbrain_tool_use_id populated.
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, tool_use_id, tool_name, status, ordinal,
gbrain_tool_use_id::text AS gbrain_tool_use_id, schema_version
FROM subagent_tool_executions
WHERE job_id = $1`,
[jobId],
);
expect(toolRows.length).toBe(1);
expect(toolRows[0].tool_name).toBe('search');
expect(toolRows[0].status).toBe('complete');
expect(toolRows[0].ordinal).toBe(0);
expect(toolRows[0].schema_version).toBe(2); // v0.38 write
expect(String(toolRows[0].gbrain_tool_use_id)).toMatch(/^[0-9a-f-]{36}$/); // UUID v7
expect(toolRows[0].tool_use_id).toBe('provider-tc-1'); // provider id preserved
// Token accumulation across both turns.
expect(result.tokens.in).toBe(45); // 20 + 25
expect(result.tokens.out).toBe(12); // 8 + 4
});
it('tool error path: handler persists status=failed, loop continues with error feedback', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc-fail', toolName: 'always_fail', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'sorry that failed',
blocks: [{ type: 'text', text: 'sorry that failed' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 8, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'try', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.result).toBe('sorry that failed');
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRows[0].status).toBe('failed');
expect(String(toolRows[0].error)).toContain('intentional tool failure');
});
it('max_turns: loop terminates when budget exhausted', async () => {
// Always return tool_calls — never end. Should hit max_turns cap (default 20 in subagent.ts).
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: `tc-${Math.random()}`, toolName: 'search', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({
prompt: 'loop forever',
model: 'anthropic:claude-sonnet-4-6',
});
// Override max_turns via data so the test runs in <1s.
ctx.data.max_turns = 3;
const result = await handler(ctx);
expect(result.stop_reason).toBe('max_turns');
// 3 tool dispatches over 3 turns (max_turns cap).
expect(executions.length).toBe(3);
});
it('refusal stop reason: handler maps refusal → SubagentStopReason refusal', async () => {
__setChatTransportForTests(async () => ({
text: 'I cannot help with that',
blocks: [{ type: 'text', text: 'I cannot help with that' }] as ChatBlock[],
stopReason: 'refusal',
usage: { input_tokens: 5, output_tokens: 7, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({ prompt: 'bad request', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('refusal');
expect(result.result).toBe('I cannot help with that');
});
it('non-Anthropic model routes through gateway path (the load-bearing v0.38 unlock)', async () => {
// This is the headline scenario: openai:gpt-5.2 (no caching) works.
// Pre-v0.38, this would have refused at queue.ts. With the gateway path
// flag on, the loop runs end-to-end.
__setChatTransportForTests(async () => ({
text: 'gpt-5 says hi',
blocks: [{ type: 'text', text: 'gpt-5 says hi' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 4, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-5.2',
providerId: 'openai',
} satisfies ChatResult));
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({ prompt: 'hi', model: 'openai:gpt-5.2' });
const result = await handler(ctx);
expect(result.result).toBe('gpt-5 says hi');
expect(result.stop_reason).toBe('end_turn');
});
it('write-ordering invariant: assistant message persisted BEFORE tool pending row', async () => {
// The D11 + codex P1 write-ordering invariant: persistence callbacks
// fire in order so a SIGKILL between any two steps leaves the DB in a
// reconcilable state. This test asserts the message_idx of the assistant
// is strictly less than any subagent_tool_executions row that references it.
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'order-tc', toolName: 'search', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'done',
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'go', model: 'anthropic:claude-sonnet-4-6' });
await handler(ctx);
// Find the assistant turn that contains the tool call.
const msgs = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`,
[jobId],
);
const assistantIdx = (msgs.find(m => m.role === 'assistant') as any).message_idx;
const toolRow = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRow[0].message_idx).toBe(assistantIdx);
// Both rows present means the order completed correctly (assistant first,
// tool exec keyed to it, follow-up user with results, second assistant).
expect(msgs.length).toBeGreaterThanOrEqual(3);
});
});