Files
gbrain/test/subagent-aggregator.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

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();
});
});