mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Five new modules + one SPA tab + one CLI command, all wired into the v0.41 audit substrate from migration v93. Each module is unit-tested in isolation; integration smoke tests live in the e2e suite. NEW MODULES: src/core/minions/error-classify.ts (D3 + E6 shared classifier) Conservative regex set classifying minion_jobs.last_error into stable buckets. Narrowed tool-error sub-types per codex pass-2 #4: only tool_schema_mismatch self-fixes; tool_crash + tool_unavailable + tool_permission stay visible. RECOVERABLE_CLUSTERS export gates E6 self-fix qualification. clusterErrors() groups + sorts for D3 surfaces. Pinned by 21 cases against real production error strings. src/core/minions/batch-projection.ts (D4 submit-time projection) Pure-function projectBatch() computes total cost + duration with ±30% band (or sample-stddev when historical). Cold-start fallback uses model-default per-token pricing + 5s mean latency guess; annotates "(no history; estimate is a wide guess)" so operators don't trust approximations. Unknown-model returns tagged variant so --budget-usd refuses to gate. Raise-cap hint fires when lease is binding AND a 4x raise meaningfully helps. Pinned by 16 cases. src/core/minions/budget-tracker.ts (D5 + Eng D7 + Eng D10) Reservation pattern that bounds overspend even under N parallel children of one owner. SQL UPDATE CAS WHERE budget_remaining_cents >= cost RETURNING balance; CAS miss → BudgetExhausted; on return → refundBudget unspent cents. Eng D10 NULL-bypass: jobs without an owner skip reservation cleanly. Eng D10 owner-deleted disambiguation: when budget_owner_job_id is NULL but budget_root_owner_id is set, the owner was pruned mid-batch; child throws BudgetOwnerDeleted instead of silently bypassing. haltBudgetSubtree() recursive halt walks budget_owner_job_id = X to flip the entire subtree to dead with reason. Pinned by 10 cases covering: reservation+refund, CAS miss, NULL bypass, owner-deleted throw, halt sweep, grandchild inheritance, active-job preservation. NEW SURFACES: src/commands/jobs-watch.ts + GET /admin/api/jobs/watch + JobsWatchPage Live TTY dashboard via readSnapshot() + renderSnapshot(). 1s refresh, ANSI-colored lease pressure by severity, top-5 clustered errors, budget owners panel. Non-TTY mode emits JSON snapshots per tick. Admin SPA tab consumes the same /admin/api/jobs/watch endpoint so TTY + browser dashboards stay 1:1. src/commands/jobs.ts — --cluster-errors flag on `gbrain jobs stats` Groups dead/failed jobs from last 24h by classifier bucket; surfaces top 5 with paste-ready `gbrain jobs get <id>` example. src/core/minions/types.ts — SubagentHandlerData additions no_self_fix (E6 per-job opt-out), is_self_fix_child (chain-depth marker), self_fix_cluster (audit metadata). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
6.8 KiB
TypeScript
154 lines
6.8 KiB
TypeScript
/**
|
|
* v0.41 D3 + E6 — error classifier unit tests.
|
|
*
|
|
* Pins each bucket against real production error string shapes from
|
|
* `minion_jobs.last_error`. Covers the narrowed tool-error sub-buckets
|
|
* per codex pass-2 #4 (only `tool_schema_mismatch` self-fixes; `tool_crash`
|
|
* / `tool_unavailable` / `tool_permission` route through normal dead-letter).
|
|
*
|
|
* RECOVERABLE_CLUSTERS guard test pins the E6 self-fix qualification list
|
|
* so future widening is an explicit code change, not silent drift.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
classifyJobError,
|
|
clusterErrors,
|
|
RECOVERABLE_CLUSTERS,
|
|
} from '../src/core/minions/error-classify.ts';
|
|
|
|
describe('classifyJobError', () => {
|
|
test('null / undefined / empty → unknown', () => {
|
|
expect(classifyJobError(null)).toBe('unknown');
|
|
expect(classifyJobError(undefined)).toBe('unknown');
|
|
expect(classifyJobError('')).toBe('unknown');
|
|
});
|
|
|
|
test('rate_lease_full — gbrain internal', () => {
|
|
expect(classifyJobError('rate lease "anthropic:messages" full (8/8)')).toBe('rate_lease_full');
|
|
expect(classifyJobError('rate lease "openai:responses" full (32/32)')).toBe('rate_lease_full');
|
|
});
|
|
|
|
test('prompt_too_long — Anthropic 400', () => {
|
|
expect(classifyJobError('400 prompt is too long: 1707509 tokens > 1000000 maximum')).toBe('prompt_too_long');
|
|
expect(classifyJobError('context length exceeded')).toBe('prompt_too_long');
|
|
});
|
|
|
|
test('tool_unavailable — registry config', () => {
|
|
expect(classifyJobError('tool "ghost" is not in the registry for this subagent')).toBe('tool_unavailable');
|
|
expect(classifyJobError('tool "shell" is not available')).toBe('tool_unavailable');
|
|
});
|
|
|
|
test('tool_permission — capability decision', () => {
|
|
expect(classifyJobError('tool "put_page" permission denied: slug outside namespace')).toBe('tool_permission');
|
|
expect(classifyJobError('tool "shell" forbidden')).toBe('tool_permission');
|
|
});
|
|
|
|
test('tool_schema_mismatch — bad args (self-fixable)', () => {
|
|
expect(classifyJobError('invalid input: missing required field "slug"')).toBe('tool_schema_mismatch');
|
|
expect(classifyJobError('tool_use validation failed: param X required')).toBe('tool_schema_mismatch');
|
|
expect(classifyJobError('malformed argument shape')).toBe('tool_schema_mismatch');
|
|
});
|
|
|
|
test('tool_crash — real impl bug (NOT self-fixable)', () => {
|
|
expect(classifyJobError('tool "git_commit" failed: ENOENT')).toBe('tool_crash');
|
|
expect(classifyJobError('tool.execute error: Cannot read property of undefined')).toBe('tool_crash');
|
|
});
|
|
|
|
test('malformed_json — model emitted bad JSON (self-fixable)', () => {
|
|
expect(classifyJobError('failed to parse JSON response')).toBe('malformed_json');
|
|
expect(classifyJobError('Unexpected token } in JSON at position 47')).toBe('malformed_json');
|
|
expect(classifyJobError('expected JSON, got plain text')).toBe('malformed_json');
|
|
});
|
|
|
|
test('auth — 401 / API key', () => {
|
|
expect(classifyJobError('401 Unauthorized: invalid API key')).toBe('auth');
|
|
expect(classifyJobError('api_key_invalid')).toBe('auth');
|
|
});
|
|
|
|
test('rate_limit — upstream 429', () => {
|
|
expect(classifyJobError('429 Too Many Requests')).toBe('rate_limit');
|
|
expect(classifyJobError('rate limit exceeded')).toBe('rate_limit');
|
|
});
|
|
|
|
test('http_5xx — upstream server errors', () => {
|
|
expect(classifyJobError('500 Internal Server Error')).toBe('http_5xx');
|
|
expect(classifyJobError('502 Bad Gateway')).toBe('http_5xx');
|
|
expect(classifyJobError('Anthropic API: overloaded_error')).toBe('http_5xx');
|
|
});
|
|
|
|
test('timeout — local timeout', () => {
|
|
expect(classifyJobError('aborted: timeout')).toBe('timeout');
|
|
expect(classifyJobError('operation timed out after 30s')).toBe('timeout');
|
|
});
|
|
|
|
test('context_canceled — abort signal', () => {
|
|
expect(classifyJobError('aborted: cancel')).toBe('context_canceled');
|
|
expect(classifyJobError('signal aborted: worker shutdown')).toBe('context_canceled');
|
|
});
|
|
|
|
test('unknown — no pattern matches (operator-visible signal to widen classifier)', () => {
|
|
expect(classifyJobError('mysterious novel error message')).toBe('unknown');
|
|
expect(classifyJobError('catastrophic failure at 03:14:00')).toBe('unknown');
|
|
});
|
|
|
|
test('most-specific wins — tool_unavailable beats tool_crash for "not in registry" message', () => {
|
|
// "tool 'ghost' is not in the registry" could superficially match a
|
|
// looser tool_crash regex; classifier order ensures unavailable wins.
|
|
expect(classifyJobError('tool "ghost" is not in the registry for this subagent')).toBe('tool_unavailable');
|
|
});
|
|
});
|
|
|
|
describe('clusterErrors', () => {
|
|
test('groups by bucket; returns sorted by count desc, then bucket asc', () => {
|
|
const errors = [
|
|
{ id: 1, last_error: 'rate lease "anthropic:messages" full (8/8)' },
|
|
{ id: 2, last_error: 'rate lease "anthropic:messages" full (8/8)' },
|
|
{ id: 3, last_error: 'rate lease "anthropic:messages" full (8/8)' },
|
|
{ id: 4, last_error: 'prompt is too long: 1.8M tokens' },
|
|
{ id: 5, last_error: '500 Internal Server Error' },
|
|
];
|
|
const clusters = clusterErrors(errors);
|
|
expect(clusters[0]!.cluster).toBe('rate_lease_full');
|
|
expect(clusters[0]!.count).toBe(3);
|
|
expect(clusters[0]!.sample_ids).toEqual([1, 2, 3]);
|
|
// Then prompt_too_long (1) before http_5xx (1) by alpha tiebreaker.
|
|
expect(clusters[1]!.cluster).toBe('http_5xx');
|
|
expect(clusters[2]!.cluster).toBe('prompt_too_long');
|
|
});
|
|
|
|
test('caps sample_ids at 3 per bucket', () => {
|
|
const errors = Array.from({ length: 10 }, (_, i) => ({
|
|
id: i + 1,
|
|
last_error: 'rate lease "anthropic:messages" full (8/8)',
|
|
}));
|
|
const clusters = clusterErrors(errors);
|
|
expect(clusters[0]!.sample_ids).toEqual([1, 2, 3]);
|
|
expect(clusters[0]!.count).toBe(10);
|
|
});
|
|
|
|
test('empty input → empty array', () => {
|
|
expect(clusterErrors([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('RECOVERABLE_CLUSTERS guard (codex pass-2 #4)', () => {
|
|
test('only the three narrowed buckets self-fix', () => {
|
|
expect(RECOVERABLE_CLUSTERS.has('prompt_too_long')).toBe(true);
|
|
expect(RECOVERABLE_CLUSTERS.has('tool_schema_mismatch')).toBe(true);
|
|
expect(RECOVERABLE_CLUSTERS.has('malformed_json')).toBe(true);
|
|
});
|
|
|
|
test('tool_crash + tool_unavailable + tool_permission are NOT self-fixable', () => {
|
|
expect(RECOVERABLE_CLUSTERS.has('tool_crash')).toBe(false);
|
|
expect(RECOVERABLE_CLUSTERS.has('tool_unavailable')).toBe(false);
|
|
expect(RECOVERABLE_CLUSTERS.has('tool_permission')).toBe(false);
|
|
});
|
|
|
|
test('rate_lease_full does NOT self-fix (Bug 2 handles it differently)', () => {
|
|
// The lease-full bypass path is the right handler for this; self-fix
|
|
// would mask the cap-too-tight signal the operator needs to see.
|
|
expect(RECOVERABLE_CLUSTERS.has('rate_lease_full')).toBe(false);
|
|
});
|
|
});
|