mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 08:13:12 +00:00
* 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>
428 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|