fix(subagent): v0.16.3 — bind Anthropic SDK correctly + enable tsc in CI (#318)

* fix(subagent): bind Anthropic SDK messages.create() correctly

The makeSubagentHandler was casting `new Anthropic()` directly to
MessagesClient, but MessagesClient.create() maps to sdk.messages.create(),
not sdk.create(). Every subagent job immediately died with:

  client.create is not a function

Fix: wrap the SDK instance so .create() delegates to .messages.create()
with proper `this` binding via .bind(sdk.messages).

Discovered on first production run of gbrain agent against Supabase.

Co-Authored-By: Wintermute <wintermute@openclaw.ai>

* chore(ci): add typescript typecheck to test pipeline + clean up baseline errors

Root cause infra gap that let the v0.16.0 subagent bug ship: CI ran
only `bun test`, which transpiles types without checking them. Type
errors only surfaced at runtime, in production.

Changes:
- Add `typescript` devDep and a `typecheck` npm script (`tsc --noEmit`).
- Chain `bun run typecheck` into `bun run test` so developers get the
  same pipeline locally that CI runs.
- Flip `.github/workflows/test.yml` to invoke `bun run test` (the npm
  script, including typecheck) instead of `bun test` (runner only).
- Clean up 100+ pre-existing type errors across 30+ files so the first
  run of `tsc --noEmit` is green. Root causes were:
  - `databaseUrl` → `database_url` rename drift in test fixtures (9 files)
  - `PageType` union missing `'meeting'` / `'note'` entries that are
    already used in both src and tests (link-extraction.ts comments
    acknowledged the gap)
  - `GBrainConfig.storage` field never declared despite being read in
    files.ts and operations.ts
  - `ErrorCode` union missing `'permission_denied'`
  - `OrchestratorOpts` shape changed; test callers not updated
  - Dead-code comparisons in migration orchestrators against narrowed
    status types
  - postgres.js `Row`-callback type drift on several `.map()` calls
  - Buffer-as-BodyInit assignment in supabase.ts (real but non-fatal
    runtime bug; Uint8Array slice works and is type-correct)
  - Various `as X` single-step casts that now need `as unknown as X`
    per TS's stricter structural-conversion rules
- Bump `beforeAll` hook timeout to 30s on four PGLite-heavy tests that
  were flaky under parallel test execution: wait-for-completion,
  extract-fs, e2e/search-quality, e2e/graph-quality. All pass in
  isolation; timeouts only happened when dozens of PGLite instances
  init'd simultaneously.

The new CI pipeline now fails on any type error across src/ or test/,
giving us the compile-time regression guard the subagent fix depends on.

* fix(subagent): bind Anthropic SDK messages.create() correctly

Shipped bug: v0.16.0 cast `new Anthropic()` to `MessagesClient`, but
`.create()` lives at `sdk.messages.create`, not on the top-level client.
Every subagent job in production died on first LLM call with
`client.create is not a function`. Discovered on the first `gbrain agent
run` against Supabase.

Fix: assign `sdk.messages` directly to the `MessagesClient` slot.
`sdk.messages` IS the object with a callable `.create()`; the original
bug was picking the wrong entry point on the SDK. No helper, no
wrapper, no `.bind()` — JS method-call semantics preserve `this` at
the call site because `subagent.ts:336` invokes `client.create(...)`
with `client === sdk.messages`.

The one-line assignment also typechecks cleanly against the existing
`MessagesClient` interface (SDK's first `create` overload:
`(MessageCreateParamsNonStreaming, Core.RequestOptions?) =>
APIPromise<Message>` is assignable structurally). This gives us
compile-time regression protection: anyone reverting to
`new Anthropic()` would fail tsc because `Anthropic` has no top-level
`.create`. (The companion chore commit puts `tsc --noEmit` in CI so
this guard is enforced.)

Also adds a `makeAnthropic?: () => Anthropic` dep-injection seam so
the factory default construction branch is testable without real API
calls. Regression test drives one handler turn through a fake SDK,
asserting `sdk.messages.create` is actually called. If someone later
reverts to `new Anthropic()`, both guards fire: tsc fails AND the test
fails.

Co-Authored-By: Wintermute <wintermute@garrytan.com>

* chore(tests): add bunfig.toml + 60s hook timeouts to stabilize PGLite-heavy suites

After turning on tsc in CI (previous commit), running the full `bun run test`
suite in one shot triggered flaky `beforeEach/afterEach hook timed out`
failures on 8+ test files. Every failure traced to PGLite WASM init
contention when many test files spin up fresh PGLite instances in parallel;
each one alone passes in isolation.

- `bunfig.toml` sets the global test hook timeout to 60s (default is 5s),
  covering every test file without per-file edits.
- Individual `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` calls on
  the 8 tests that flaked most stay in place as explicit safety nets so
  a future bunfig config change doesn't silently re-introduce the flake.

Result: 1997 pass, 0 fail on `bun run test` (117 tests added since the
prior baseline by picking up typecheck-gated passes). No infrastructure
flake tolerated in CI.

* chore: bump version and changelog (v0.16.3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Wintermute <wintermute@openclaw.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-22 01:34:22 -07:00
committed by GitHub
parent 418d955fd3
commit 96178d726e
48 changed files with 263 additions and 98 deletions

View File

@@ -421,7 +421,7 @@ async function mirrorFiles(args: string[]) {
// Write .supabase marker
const marker = stringify({
synced_at: new Date().toISOString(),
bucket: config.storage.bucket || 'brain-files',
bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files',
prefix: basename(dir) + '/',
file_count: uploaded,
});

View File

@@ -38,12 +38,13 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
// Find dir: first non-flag arg that isn't a value for --workers
const flagValues = new Set<number>();
if (workersIdx !== -1) flagValues.add(workersIdx + 1);
const dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
if (!dir) {
if (!dirArg) {
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--json]');
process.exit(1);
}
const dir: string = dirArg; // narrowed; survives closure capture
// Collect all .md files
const allFiles = collectMarkdownFiles(dir);

View File

@@ -217,10 +217,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
phases.push(e);
// F. Record
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const overallStatus: 'complete' | 'partial' | 'failed' =
a.status === 'failed' ? 'failed' :
phases.some(p => p.status === 'failed') ? 'partial' :
'complete';
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
return finalizeResult(phases, overallStatus);
}

View File

@@ -105,10 +105,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
a.status === 'failed' || b.status === 'failed' ? 'failed' :
c.status === 'failed' ? 'partial' :
'complete';
c.status === 'failed' ? 'partial' : 'complete';
return finalizeResult(phases, overallStatus);
}

View File

@@ -129,10 +129,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
a.status === 'failed' || b.status === 'failed' ? 'failed' :
c.status === 'failed' ? 'partial' :
'complete';
c.status === 'failed' ? 'partial' : 'complete';
return finalizeResult(phases, overallStatus);
}

View File

@@ -95,10 +95,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const b = await phaseBVerify(opts);
phases.push(b);
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const status: 'complete' | 'partial' | 'failed' =
a.status === 'failed' ? 'failed' :
b.status === 'failed' ? 'partial' :
'complete';
b.status === 'failed' ? 'partial' : 'complete';
return finalize(phases, status);
}

View File

@@ -115,7 +115,7 @@ export async function queryOrphanPages(): Promise<{ slug: string; title: string;
)
ORDER BY p.slug
`;
return rows as { slug: string; title: string; domain: string | null }[];
return rows as unknown as { slug: string; title: string; domain: string | null }[];
}
/**

View File

@@ -117,7 +117,7 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as { n: number }).n;
repaired = (rows[0] as unknown as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}

View File

@@ -25,9 +25,12 @@ import { xHandleToTweetResolver } from '../core/resolvers/builtin/x-api/handle-t
* to call from multiple entry points.
*/
export function registerBuiltinResolvers(registry = getDefaultRegistry()): void {
const builtins = [urlReachableResolver, xHandleToTweetResolver] as const;
// Cast each element to the widest shape the registry accepts. The tuple
// element types diverge (different Input/Output generics) so the union
// type would not satisfy registry.register's single-signature parameter.
const builtins = [urlReachableResolver, xHandleToTweetResolver];
for (const r of builtins) {
if (!registry.has(r.id)) registry.register(r);
if (!registry.has(r.id)) registry.register(r as Parameters<typeof registry.register>[0]);
}
}

View File

@@ -29,6 +29,13 @@ export interface GBrainConfig {
database_path?: string;
openai_api_key?: string;
anthropic_api_key?: string;
/**
* Optional storage backend config (S3/Supabase/local). Shape matches
* `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid
* a cyclic import; callers pass this through `createStorage()` which
* validates the shape at runtime.
*/
storage?: unknown;
}
/**

View File

@@ -159,5 +159,5 @@ export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) =
const conn = getConnection();
return conn.begin(async (tx) => {
return fn(tx as unknown as ReturnType<typeof postgres>);
});
}) as Promise<T>;
}

View File

@@ -113,8 +113,8 @@ export async function enrichEntity(
let timelineAdded = false;
try {
await engine.addTimelineEntry(slug, {
date: new Date().toISOString().split('T')[0],
content: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
date: new Date().toISOString().split('T')[0] ?? '',
summary: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
source: request.sourceSlug,
});
timelineAdded = true;
@@ -161,7 +161,7 @@ export async function enrichEntities(
}
const result = await enrichEntity(engine, req);
results.push(result);
config?.onProgress?.(results.length, requests.length, req.name);
config?.onProgress?.(results.length, requests.length, req.entityName);
}
return results;
}

View File

@@ -119,18 +119,18 @@ export async function resolveFile(
/** Parse v0.9+ .redirect.yaml pointer */
export function parseRedirectYaml(path: string): RedirectYaml {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as RedirectYaml;
return parseYaml(content) as unknown as RedirectYaml;
}
/** Parse legacy v0.8 .redirect breadcrumb */
export function parseRedirect(path: string): RedirectInfo {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as RedirectInfo;
return parseYaml(content) as unknown as RedirectInfo;
}
export function parseMarker(path: string): MarkerInfo {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as MarkerInfo;
return parseYaml(content) as unknown as MarkerInfo;
}
/** Human-readable file size */

View File

@@ -269,7 +269,7 @@ export async function shellHandler(ctx: MinionJobContext): Promise<ShellJobResul
if (ctx.signal.aborted) sigAbort();
if (ctx.shutdownSignal.aborted) shutdownAbort();
const exitCode: number = await new Promise((resolve, reject) => {
const exitCode: number = await new Promise<number>((resolve, reject) => {
proc.on('error', (err) => {
reject(err);
});

View File

@@ -41,7 +41,7 @@ export interface AggregatorResult {
/** v0.15 aggregator: synchronous read from inbox, no LLM call. */
export async function subagentAggregatorHandler(ctx: MinionJobContext): Promise<AggregatorResult> {
const data = (ctx.data ?? {}) as AggregatorHandlerData;
const data = (ctx.data ?? {}) as unknown as AggregatorHandlerData;
const expectedIds = Array.isArray(data.children_ids) ? data.children_ids : [];
if (expectedIds.length === 0) {

View File

@@ -71,6 +71,13 @@ export interface SubagentDeps {
engine: BrainEngine;
/** Anthropic client. Defaults to the SDK-constructed client. */
client?: MessagesClient;
/**
* Anthropic SDK constructor. Defaults to `() => new Anthropic()`.
* Overridable in tests so the factory default-client branch is
* exercisable without an ANTHROPIC_API_KEY or a real API call.
* When `deps.client` is provided, this is unused.
*/
makeAnthropic?: () => Anthropic;
/** Config (MCP, brain, etc.). Defaults to loadConfig(). */
config?: GBrainConfig;
/** Rate-lease key. Defaults to `anthropic:messages`. */
@@ -119,15 +126,20 @@ interface PersistedToolExec {
*/
export function makeSubagentHandler(deps: SubagentDeps) {
const engine = deps.engine;
const client: MessagesClient =
deps.client ?? (new Anthropic() as unknown as MessagesClient);
// sdk.messages IS the MessagesClient-shaped object. The v0.16.0 bug was
// casting new Anthropic() (top level) to MessagesClient, but .create()
// lives at sdk.messages.create. Assigning sdk.messages directly gets the
// right object; JS method-call semantics preserve `this` at the call
// site (subagent.ts invokes client.create(...) with client === sdk.messages).
const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic());
const client: MessagesClient = deps.client ?? makeAnthropic().messages;
const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig);
const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY;
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
return async function subagentHandler(ctx: MinionJobContext): Promise<SubagentResult> {
const data = (ctx.data ?? {}) as SubagentHandlerData;
const data = (ctx.data ?? {}) as unknown as SubagentHandlerData;
if (!data.prompt || typeof data.prompt !== 'string') {
throw new Error('subagent job data.prompt is required (string)');
}

View File

@@ -30,7 +30,7 @@ import { evaluateQuietHours, type QuietHoursConfig } from './quiet-hours.ts';
function readQuietHoursConfig(job: MinionJob): QuietHoursConfig | null {
const cfg = (job as MinionJob & { quiet_hours?: unknown }).quiet_hours;
if (!cfg || typeof cfg !== 'object') return null;
return cfg as QuietHoursConfig;
return cfg as unknown as QuietHoursConfig;
}
/** Per-job in-flight state (isolated per job, not shared on the worker). */

View File

@@ -24,7 +24,8 @@ export type ErrorCode =
| 'embedding_failed'
| 'storage_error'
| 'bucket_not_found'
| 'database_error';
| 'database_error'
| 'permission_denied';
export class OperationError extends Error {
constructor(

View File

@@ -113,7 +113,7 @@ export class PGLiteEngine implements BrainEngine {
async putPage(slug: string, page: PageInput): Promise<Page> {
slug = validateSlug(slug);
const hash = page.content_hash || contentHash(page.compiled_truth, page.timeline || '');
const hash = page.content_hash || contentHash(page);
const frontmatter = page.frontmatter || {};
const { rows } = await this.db.query(

View File

@@ -59,7 +59,9 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
return { lockDir: '', acquired: true };
}
mkdirSync(dataDir, { recursive: true });
// `lockDir` being set implies `dataDir` is set (see getLockDir), but TS
// can't derive that across helper boundaries.
mkdirSync(dataDir as string, { recursive: true });
const timeoutMs = opts?.timeoutMs ?? 30_000; // 30 second default timeout
const startTime = Date.now();

View File

@@ -4,7 +4,7 @@ import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts';
import { runMigrations } from './migrate.ts';
import { SCHEMA_SQL } from './schema-embedded.ts';
import type {
Page, PageInput, PageFilters,
Page, PageInput, PageFilters, PageType,
Chunk, ChunkInput,
SearchResult, SearchOpts,
Link, GraphNode, GraphPath,
@@ -95,7 +95,7 @@ export class PostgresEngine implements BrainEngine {
Object.defineProperty(txEngine, 'sql', { get: () => tx });
Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType<typeof postgres>, writable: false });
return fn(txEngine);
});
}) as Promise<T>;
}
// Pages CRUD
@@ -117,7 +117,7 @@ export class PostgresEngine implements BrainEngine {
const rows = await sql`
INSERT INTO pages (slug, type, title, compiled_truth, timeline, frontmatter, content_hash, updated_at)
VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter)}, ${hash}, now())
VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters<typeof sql.json>[0])}, ${hash}, now())
ON CONFLICT (slug) DO UPDATE SET
type = EXCLUDED.type,
title = EXCLUDED.title,
@@ -165,7 +165,7 @@ export class PostgresEngine implements BrainEngine {
async getAllSlugs(): Promise<Set<string>> {
const sql = this.sql;
const rows = await sql`SELECT slug FROM pages`;
return new Set(rows.map((r: { slug: string }) => r.slug));
return new Set(rows.map((r) => r.slug as string));
}
async resolveSlugs(partial: string): Promise<string[]> {
@@ -183,7 +183,7 @@ export class PostgresEngine implements BrainEngine {
ORDER BY sim DESC
LIMIT 5
`;
return fuzzy.map((r: { slug: string }) => r.slug);
return fuzzy.map((r) => r.slug as string);
}
// Search
@@ -344,7 +344,7 @@ export class PostgresEngine implements BrainEngine {
model = COALESCE(EXCLUDED.model, content_chunks.model),
token_count = EXCLUDED.token_count,
embedded_at = COALESCE(EXCLUDED.embedded_at, content_chunks.embedded_at)`,
params,
params as Parameters<typeof sql.unsafe>[1],
);
}
@@ -356,7 +356,7 @@ export class PostgresEngine implements BrainEngine {
WHERE p.slug = ${slug}
ORDER BY cc.chunk_index
`;
return rows.map(rowToChunk);
return rows.map((r) => rowToChunk(r as Record<string, unknown>));
}
async deleteChunks(slug: string): Promise<void> {
@@ -693,7 +693,7 @@ export class PostgresEngine implements BrainEngine {
WHERE p.slug = ANY(${slugs}::text[])
GROUP BY p.slug
`;
for (const r of rows as { slug: string; cnt: number }[]) {
for (const r of rows as unknown as { slug: string; cnt: number }[]) {
result.set(r.slug, Number(r.cnt));
}
return result;
@@ -729,7 +729,7 @@ export class PostgresEngine implements BrainEngine {
WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug})
ORDER BY tag
`;
return rows.map((r: { tag: string }) => r.tag);
return rows.map((r) => r.tag as string);
}
// Timeline
@@ -814,7 +814,7 @@ export class PostgresEngine implements BrainEngine {
const sql = this.sql;
const result = await sql`
INSERT INTO raw_data (page_id, source, data)
SELECT id, ${source}, ${sql.json(data as Record<string, unknown>)}
SELECT id, ${source}, ${sql.json(data as Parameters<typeof sql.json>[0])}
FROM pages WHERE slug = ${slug}
ON CONFLICT (page_id, source) DO UPDATE SET
data = EXCLUDED.data,
@@ -987,7 +987,7 @@ export class PostgresEngine implements BrainEngine {
dead_links: deadLinks,
link_coverage: Number(h.link_coverage),
timeline_coverage: Number(h.timeline_coverage),
most_connected: (connected as { slug: string; link_count: number }[]).map(c => ({
most_connected: (connected as unknown as { slug: string; link_count: number }[]).map(c => ({
slug: c.slug,
link_count: Number(c.link_count),
})),
@@ -1060,11 +1060,11 @@ export class PostgresEngine implements BrainEngine {
WHERE p.slug = ${slug}
ORDER BY cc.chunk_index
`;
return rows.map((r: Record<string, unknown>) => rowToChunk(r, true));
return rows.map((r) => rowToChunk(r as Record<string, unknown>, true));
}
async executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
const conn = this.sql;
return conn.unsafe(sql, params) as unknown as T[];
return conn.unsafe(sql, params as Parameters<typeof conn.unsafe>[1]) as unknown as T[];
}
}

View File

@@ -55,7 +55,7 @@ export class SupabaseStorage implements StorageBackend {
'Content-Type': mime || 'application/octet-stream',
'x-upsert': 'true',
},
body: data,
body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as BodyInit,
});
if (!res.ok) {
const body = await res.text();
@@ -126,7 +126,7 @@ export class SupabaseStorage implements StorageBackend {
'Content-Type': 'application/offset+octet-stream',
'Content-Length': String(chunk.length),
},
body: chunk,
body: new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) as BodyInit,
});
if (!patchRes.ok) {

View File

@@ -1,5 +1,5 @@
// Page types
export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture';
export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture' | 'meeting' | 'note';
export interface Page {
id: number;