mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +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>
192 lines
7.2 KiB
TypeScript
192 lines
7.2 KiB
TypeScript
/**
|
|
* subagent_aggregator handler tests.
|
|
*
|
|
* The handler's contract is:
|
|
* - read child_done messages from the inbox (already posted by Lane 1B's
|
|
* queue changes on every terminal child transition)
|
|
* - render a markdown summary
|
|
* - return {children, summary, markdown}
|
|
*
|
|
* Tests use a synthetic MinionJobContext that serves a scripted inbox,
|
|
* tracks progress/log writes, and records them so assertions can check
|
|
* that the handler does the right bookkeeping.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
subagentAggregatorHandler,
|
|
__testing,
|
|
} from '../src/core/minions/handlers/subagent-aggregator.ts';
|
|
import type { MinionJobContext, ChildDoneMessage, InboxMessage, ChildOutcome } from '../src/core/minions/types.ts';
|
|
|
|
function ctxWithInbox(
|
|
jobId: number,
|
|
data: Record<string, unknown>,
|
|
inbox: ChildDoneMessage[],
|
|
): MinionJobContext & { _progress: unknown[]; _logs: string[] } {
|
|
const progress: unknown[] = [];
|
|
const logs: string[] = [];
|
|
const inboxMessages: InboxMessage[] = inbox.map((payload, i) => ({
|
|
id: i + 1,
|
|
job_id: jobId,
|
|
sender: 'minions',
|
|
payload: payload as unknown,
|
|
sent_at: new Date(),
|
|
read_at: null,
|
|
}));
|
|
const ctx = {
|
|
id: jobId,
|
|
name: 'subagent_aggregator',
|
|
data,
|
|
attempts_made: 0,
|
|
signal: new AbortController().signal,
|
|
deadlineAtMs: null,
|
|
shutdownSignal: new AbortController().signal,
|
|
async updateProgress(p: unknown) { progress.push(p); },
|
|
async updateTokens() {},
|
|
async log(m: string | unknown) { logs.push(typeof m === 'string' ? m : JSON.stringify(m)); },
|
|
async isActive() { return true; },
|
|
async readInbox() { return inboxMessages; },
|
|
_progress: progress,
|
|
_logs: logs,
|
|
};
|
|
return ctx as MinionJobContext & { _progress: unknown[]; _logs: string[] };
|
|
}
|
|
|
|
function done(child_id: number, outcome: ChildOutcome, overrides: Partial<ChildDoneMessage> = {}): ChildDoneMessage {
|
|
return {
|
|
type: 'child_done',
|
|
child_id,
|
|
job_name: `child_${child_id}`,
|
|
result: overrides.result !== undefined ? overrides.result : (outcome === 'complete' ? { ok: true } : null),
|
|
outcome,
|
|
error: overrides.error ?? (outcome === 'complete' ? null : `${outcome}`),
|
|
};
|
|
}
|
|
|
|
describe('subagent_aggregator happy paths', () => {
|
|
test('empty children_ids returns no-children marker', async () => {
|
|
const ctx = ctxWithInbox(1, {}, []);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children).toEqual([]);
|
|
expect(res.markdown).toContain('_(no children)_');
|
|
});
|
|
|
|
test('all children succeed → complete count + bracketed results', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [10, 11] }, [
|
|
done(10, 'complete', { result: { finding: 'a' } }),
|
|
done(11, 'complete', { result: { finding: 'b' } }),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children.length).toBe(2);
|
|
expect(res.summary.complete).toBe(2);
|
|
expect(res.summary.failed).toBe(0);
|
|
expect(res.markdown).toContain('## child 10');
|
|
expect(res.markdown).toContain('"finding": "a"');
|
|
});
|
|
|
|
test('mixed outcomes tallied correctly', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [1, 2, 3, 4] }, [
|
|
done(1, 'complete'),
|
|
done(2, 'failed', { error: 'boom' }),
|
|
done(3, 'cancelled'),
|
|
done(4, 'timeout'),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary).toEqual({ complete: 1, failed: 1, dead: 0, cancelled: 1, timeout: 1 });
|
|
expect(res.markdown).toContain('child 2');
|
|
expect(res.markdown).toContain('error: boom');
|
|
});
|
|
|
|
test('result is null for non-complete outcomes (no leaked payload)', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [42] }, [
|
|
done(42, 'failed', { result: 'should-be-suppressed', error: 'x' }),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children[0]!.result).toBeNull();
|
|
});
|
|
|
|
test('missing child_done is counted as failed with clear error', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [10, 11] }, [done(10, 'complete')]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
const missing = res.children.find(c => c.child_id === 11);
|
|
expect(missing?.outcome).toBe('failed');
|
|
expect(missing?.error).toContain('no child_done message observed');
|
|
expect(res.summary.failed).toBe(1);
|
|
});
|
|
|
|
test('preserves children_ids order in the output', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [3, 1, 2] }, [
|
|
done(1, 'complete'),
|
|
done(2, 'complete'),
|
|
done(3, 'complete'),
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children.map(c => c.child_id)).toEqual([3, 1, 2]);
|
|
});
|
|
|
|
test('custom aggregate_prompt_template becomes the markdown header', async () => {
|
|
const ctx = ctxWithInbox(1, {
|
|
children_ids: [1],
|
|
aggregate_prompt_template: '# My synthesis of the shard runs',
|
|
}, [done(1, 'complete')]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.markdown.startsWith('# My synthesis of the shard runs')).toBe(true);
|
|
});
|
|
|
|
test('updateProgress + log emit once per run', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [1, 2] }, [done(1, 'complete'), done(2, 'complete')]);
|
|
await subagentAggregatorHandler(ctx);
|
|
expect(ctx._progress.length).toBe(1);
|
|
expect(ctx._logs.length).toBe(1);
|
|
expect(ctx._logs[0]).toContain('aggregated 2 children');
|
|
});
|
|
});
|
|
|
|
describe('subagent_aggregator payload parsing', () => {
|
|
test('handles stringified child_done payloads (from JSONB fetch)', async () => {
|
|
const ctx = ctxWithInbox(1, { children_ids: [5] }, [
|
|
// Simulate a stringified payload (PG returns JSONB as string in some paths).
|
|
JSON.parse(JSON.stringify(done(5, 'complete'))) as ChildDoneMessage,
|
|
]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary.complete).toBe(1);
|
|
});
|
|
|
|
test('ignores non-child_done inbox messages', async () => {
|
|
const inboxHybrid = [
|
|
done(1, 'complete'),
|
|
// unrelated payload (e.g. from a future message type)
|
|
{ type: 'ping', echo: 'nope' } as unknown as ChildDoneMessage,
|
|
];
|
|
const ctx = ctxWithInbox(1, { children_ids: [1] }, inboxHybrid);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.summary.complete).toBe(1);
|
|
});
|
|
|
|
test('falls back to complete when outcome field is absent (legacy writer)', async () => {
|
|
const legacy: ChildDoneMessage = {
|
|
type: 'child_done', child_id: 99, job_name: 'legacy', result: { ok: true },
|
|
};
|
|
const ctx = ctxWithInbox(1, { children_ids: [99] }, [legacy]);
|
|
const res = await subagentAggregatorHandler(ctx);
|
|
expect(res.children[0]!.outcome).toBe('complete');
|
|
});
|
|
});
|
|
|
|
describe('internal helpers', () => {
|
|
test('formatSummary skips zero counts', () => {
|
|
const s = __testing.emptySummary();
|
|
s.complete = 3;
|
|
s.failed = 1;
|
|
expect(__testing.formatSummary(s)).toBe('complete=3, failed=1');
|
|
});
|
|
|
|
test('parseChildDone rejects obviously-bogus payloads', () => {
|
|
expect(__testing.parseChildDone(null)).toBeNull();
|
|
expect(__testing.parseChildDone({ type: 'not_child_done' })).toBeNull();
|
|
expect(__testing.parseChildDone({ type: 'child_done' })).toBeNull(); // missing child_id
|
|
expect(__testing.parseChildDone('not json')).toBeNull();
|
|
});
|
|
});
|