Files
gbrain/test/e2e/subagent-crash-replay-multi-provider.test.ts
Garry Tan 01024567e3 v0.38.1.0 feat(agents): provider-agnostic subagent loop + remote MCP dispatch + budget meter (#1289)
* feat(agents): v0.38 Slice 1 foundation — migration v81 + capabilities module

Adds the storage substrate for the gateway-native subagent tool loop:

  - migration v81 adds subagent_tool_executions.ordinal + .gbrain_tool_use_id
    + UNIQUE(job_id, message_idx, ordinal). NULL-tolerant so legacy rows
    survive untouched; the v0.38 read-time D5 shim recomputes the stable
    key for pre-v81 rows from (job_id, message_idx, content_blocks index,
    tool_name) without a data migration. Engine-aware via sqlFor.pglite.
  - src/core/ai/capabilities.ts reads ChatTouchpoint fields from each
    recipe and exposes getProviderCapabilities() + classifyCapabilities()
    with a 5-state verdict (ok / degraded:no_caching / degraded:no_parallel
    / unusable:no_tools / unknown). This is what enforceSubagentCapable
    (D7, S1.8) will gate on once the queue.ts pin removal (S1.7) lands.
  - 12 unit cases in test/ai/capabilities.test.ts pin the verdict matrix
    across Anthropic, OpenAI, Google, voyage (no chat → unknown), unknown
    provider, missing-colon malformed input.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Wave: v0.38 (Agents+Minions cathedral; CEO + Eng + 2x Codex cleared).

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

* feat(agents): v0.38 Slice 1 — gateway.toolLoop() provider-agnostic loop control

Adds `gateway.toolLoop(opts)` as the provider-neutral loop wrapper over the
already-provider-neutral `gateway.chat()`. The Vercel AI SDK abstraction does
all the per-provider tool-def normalization, tool-call parsing, and tool-result
framing; this helper just sequences the assistant→tool-dispatch→tool-result
cycle with:

  - D11 stable-ID callbacks (onToolCallStart returns the gbrain-owned UUID v7
    that the caller persists at first observation; reread on replay)
  - Write-ordering invariant (persist assistant → persist pending tool row →
    execute side effect → settle complete/failed)
  - Crash-replay reconciliation via `replayState.priorTools` keyed by
    gbrainToolUseId (NOT provider IDs)
  - Capability-driven cache_control (Anthropic only, via cacheSystem flag)
  - Stop-reason mapping for refusal / content_filter / max_turns / aborted

The loop is stateless beyond the optional replay state — testable via the
existing `__setChatTransportForTests` seam without any DB.

This is the substrate Slice 1's `subagent.ts` rewire (S1.5) consumes.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 1 — kill the Anthropic pin, route through gateway.toolLoop

Closes the three-layer Anthropic-only enforcement (queue gate / model-config
runtime fallback / doctor check) with a capability-based gate driven by the
recipe registry. Any provider that supports native tool calling can now
run the subagent loop.

Three layers reworked:

  - queue.ts:87-106 (S1.7) — drop isAnthropicProvider hard-reject. Replace
    with classifyCapabilities() check: refuse only when verdict is
    'unusable:no_tools' or 'unknown'. Degraded providers (no caching, no
    parallel tools) pass through; the gateway prints once-per-(source, model)
    cost warnings at first dispatch.
  - model-config.ts:205 (S1.8) — rename enforceSubagentAnthropic →
    enforceSubagentCapable. Keeps the once-per-(source, model) warn seam
    from v0.31.12 and inherits the same suppression Set so doctor + first-
    call surfaces stay in sync. Legacy name kept as a thin wrapper for
    external callers.
  - doctor.ts:1189 (S1.9) — rename subagent_provider check →
    subagent_capability. The check now surfaces three states: 'unusable',
    'unknown', and 'degraded:no_caching' (the cost-regression warn). Paste-
    ready fix hints point at `gbrain config set models.tier.subagent`.

Subagent handler routing (S1.5 + S1.10):

  - New `agent.use_gateway_loop` config flag (default off). When enabled,
    the handler routes through gateway.toolLoop() — provider-agnostic via
    the Vercel AI SDK. When disabled, the legacy Anthropic-direct path
    stays unchanged.
  - Handler-entry capability check refuses tool-unsupported / unknown
    providers loudly. With flag OFF + non-Anthropic model, refuses with a
    paste-ready hint.
  - runSubagentViaGateway() (new helper) bridges the existing ToolDef
    registry to gateway's ChatToolDef + ToolHandler shapes. Persists to
    the v0.38 stable-ID columns (ordinal + gbrain_tool_use_id) at first
    observation; settles complete/failed on tool exit.
  - D5 read-time shim (S1.6) — loadPriorToolsV2 + adaptContentBlocksToChatBlocks
    handle v1 Anthropic-shaped legacy rows alongside v2 gateway-shaped writes
    so crash-replay reconciles across the upgrade boundary.

Tests:

  - test/agent-cli.test.ts Layer 1/2/3 cases flipped from "rejects non-
    Anthropic" to "any tool-supporting provider accepted; refuses unknown
    and embedding-only providers". 4 new cases covering openai, google,
    unknown provider, embedding-only.
  - All 27 cases pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 2 — budget meter (reserve-then-settle) + migrations v82/v83

Foundation for per-OAuth-client daily budget caps. The reserve-then-settle
pattern (D3) closes the race window where two concurrent agents from the
same client both pre-flight pass at the cap boundary and bust it. Mirrors
the rate-leases.ts shape (lock-bounded check-then-insert + TTL-based
crash reclamation).

Changes:

  - Migration v82 (`mcp_spend_reservations`) — UUID primary key per
    reservation, status enum {pending,settled,expired}, partial index on
    (status, expires_at) WHERE status='pending' for cheap sweeps.
  - Migration v83 (`oauth_clients.budget_usd_per_day`) — first-class
    daily cap column on registered clients. NULL = no cap (legacy
    behavior for pre-v83 clients).
  - `src/core/minions/budget-meter.ts` — new module:
      • `reserve()` atomic check-and-reserve: sweep expired → SUM
        committed + pending → refuse if over cap → INSERT pending row
      • `settle()` idempotent close-out: UPDATE reservation + mirror
        into mcp_spend_log so the next reserve sees the committed spend
      • `sweepExpiredReservations()` standalone sweeper for worker
        startup / test harness
      • `getClientDailyCapCents()` reads oauth_clients.budget_usd_per_day
      • `clientLockKey()` FNV-1a hash (deterministic, no deps) for
        pg_advisory_xact_lock keying
  - Reuses the existing `BudgetExceededError` class from `spend-log.ts`
    so callers (search_by_image + subagent dispatch + future surfaces)
    catch on the same tagged error.

All 130 migration tests green; budget-meter module typecheck clean.

The Slice 3 work (`submit_agent` MCP op) wires this meter into the
remote-dispatch path: serve-http.ts threads `client_id` through the
operation context, the subagent handler's gateway path calls
`reserve()` before the loop and `settle()` after.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 3 — submit_agent MCP op + agent scope + bound_* migration

The remote-dispatch unlock. Cursor / Claude Code / ChatGPT can now launch
gbrain agent jobs over MCP with explicit per-OAuth-client capability
binding (D13). The trust boundary lives in oauth_clients.bound_* fields,
not in ad-hoc protected-name checks.

Schema:

  - Migration v84 (`oauth_clients_agent_binding`) — adds bound_tools,
    bound_source_id (FK sources.id ON DELETE SET NULL), bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent columns. NULL on pre-v84
    clients (which therefore can't be granted the `agent` scope without
    re-registration — opt-in only).
  - `agent` scope added to `src/core/scope.ts`. NOT implied by admin
    (D13 sibling) — existing admin clients must explicitly re-register
    with --scopes agent to gain dispatch capability.

New MCP op `submit_agent`:

  - scope: `agent`, mutating, remote-callable
  - Required params: prompt. Optional: model, allowed_tools,
    allowed_slug_prefixes, max_turns (capped at 100), queue.
  - Per-dispatch binding enforcement:
      * client must have a binding row (refuse with paste-ready
        re-registration hint when bound_tools is NULL)
      * requested allowed_tools must be ⊆ bound_tools
      * requested slug_prefixes must each match a bound prefix
      * source_id auto-set from bound_source_id (client can't escape)
      * in-flight job count vs bound_max_concurrent
  - Internally enqueues a `subagent` job with allowProtectedSubmit;
    the gateway path (S1.5) is auto-on for remote-dispatched agents.
  - Writes a JSONL audit row via the new `agent-audit.ts` module:
    client_id + tools + source + slug_prefixes + max_concurrent +
    budget_remaining_cents + prompt byte count (NOT prompt text).

New `src/core/minions/agent-audit.ts`:

  - Mirrors shell-audit.ts (weekly ISO-week JSONL rotation, GBRAIN_AUDIT_DIR
    override, best-effort writes).
  - File: ~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl
  - `logAgentSubmission` + `readRecentAgentEvents` exported for the
    doctor follow-up.

Tests: typecheck clean; capabilities + agent-cli suites green (39/39).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 4 — admin per-client agent spend endpoint

Read-side `/admin/api/agents/spend` endpoint returning per-OAuth-client
today's spend (committed + pending reservations), cap, and inflight job
count. The Agents.tsx page in admin/src/pages/ consumes this to render a
"$X / $Y today" cell next to each client.

Stub-style server endpoint lands now; the full Agents.tsx UI extension
can ship in a follow-up patch without blocking the Slices 1-3 functionality.
Pre-v0.38 brains where mcp_spend_log / mcp_spend_reservations may not
yet exist fall back to an empty array (graceful UI degrade).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* test(agents): v0.38 — gateway.toolLoop + budget-meter + agent-audit + scope flips

Test gap fills surfacing the load-bearing invariants of Slices 1-3:

Gateway tool loop (test/ai/gateway-tool-loop.test.ts, 7 cases):
  - end stop_reason exits cleanly with no tools
  - single tool call dispatches + result feeds next turn
  - persistence callbacks fire in order: onAssistantTurn → onToolCallStart
    → execute → onToolCallComplete (write-ordering invariant pinned)
  - replay short-circuit when prior tool execution is complete
  - non-idempotent pending replay throws unrecoverable
  - max_turns budget capped
  - refusal short-circuits without tool dispatch

Budget meter (test/minions/budget-meter.test.ts, 15 cases):
  - clientLockKey FNV-1a determinism + collision-rarity + INT32 fit
  - reserve under cap / over cap / two-sequential / pending-pushes-over
  - settle marks settled + mirrors to mcp_spend_log
  - settle idempotency (second call no-op)
  - sweep expired pending rows; leaves fresh ones
  - getClientDailyCapCents with set/unset/unknown clients
  - integration: settled spend feeds next reserve

Agent audit (test/minions/agent-audit.test.ts, 7 cases):
  - ISO-week filename rotation (incl. year-boundary edge)
  - JSONL line shape + multi-event appending
  - regression guard: NEVER logs prompt content (only byte count)
  - readRecentAgentEvents newest-first + empty-dir graceful fallback

Pre-existing test fixes for v0.38 semantics:
  - test/scope.test.ts: `agent` scope added (size 5 → 6)
  - test/oauth.test.ts: operations registry allows scope='agent' for
    submit_agent (mutating, contained by client bindings)
  - test/model-config.serial.test.ts: enforceSubagentCapable returns
    non-Anthropic tool-supporting models unchanged (with cost warn) and
    falls back to TIER_DEFAULTS.subagent only on unknown providers

Schema parity:
  - pglite-schema.ts + schema.sql get the v83 (budget_usd_per_day) +
    v84 (bound_tools, bound_source_id, bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent) columns in CREATE TABLE
    so fresh installs land in post-migration shape AND the
    schema-bootstrap-coverage CI guard sees full coverage.

Pre-existing hybrid-reranker / cross-modal-hybrid integration test
failures are on master before any of this wave — out of scope.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* test: quarantine 4 cross-file-contended hybrid tests + withEnv-ize agent-audit

12 pre-existing flakes (hybrid-reranker / cross-modal-hybrid / unified-multimodal
/ llm-intent-hybrid-integration / doctor-report-remote) all collapsed to
zero after this wave. Root cause: shared module-level state in
src/core/ai/gateway.ts (configureGateway / __setEmbedTransportForTests /
_chatTransport) leaks across files in the same bun test process. Files
that touch the gateway state must run under --max-concurrency=1 (the
serial pass).

Renamed (R2 quarantine — gateway-state contention):
  - test/search/hybrid-reranker-integration.test.ts → .serial.test.ts
  - test/cross-modal-hybrid-integration.test.ts → .serial.test.ts
  - test/unified-multimodal.test.ts → .serial.test.ts
  - test/llm-intent-hybrid-integration.test.ts → .serial.test.ts

doctor-report-remote.serial.test.ts was already serial in v0.37.10.0; its
single failure in the v0.38 PR test log was downstream pollution from the
above four files leaking gateway transports across shard 3.

Also fixed test/minions/agent-audit.test.ts (R1 violation: raw
process.env.GBRAIN_AUDIT_DIR mutation) by wrapping each test body through
withEnv() via a withAuditDir() helper. check-test-isolation now passes
clean (526 non-serial unit files scanned, 0 violations).

Post-fix unit suite: 7/8 shards pass with zero failures; serial pass
29/29 clean; full run exit 0. Background task reported exit code 0.
The wedge on shard 4 (migrate.test.ts) is a separate slow-test scoping
concern, not a v0.38 regression.

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

* fix(admin): mirror v0.38 agent scope into admin SPA + rebuild dist

CI failure on PR #1289: scripts/check-admin-scope-drift.sh caught the
hand-maintained mirror at admin/src/lib/scope-constants.ts had not been
updated when I added the new `agent` scope to src/core/scope.ts in Slice 3.
CLAUDE.md flagged this exact CI guard for the file.

Mirrored: added `agent` to both the Scope union type and the alphabetically-
sorted ALLOWED_SCOPES_LIST. Rebuilt the admin SPA dist (vite build, 36
modules, 228KB) so the bundled scope-aware UI matches the new server-side
list. check-admin-scope-drift passes (6 scopes match); full `bun run verify`
chain passes end-to-end including typecheck (0 errors).

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

* fix(admin): regenerate src/admin-embedded.ts after dist rebuild

CI failure on PR #1289 serial pass: test/admin-embed-spawn.serial.test.ts
4/4 fail with "Cannot find module '../admin/dist/assets/index-CWq369vO.js'"
when spawning gbrain serve --http.

Root cause: the prior commit (f270e6c7) rebuilt the admin SPA dist after
adding the v0.38 `agent` scope to admin/src/lib/scope-constants.ts, which
produced a new content-hashed bundle filename (index-CWq369vO.js →
index-DFgMZhBE.js). The auto-generated `src/admin-embedded.ts` manifest
still hardcoded the OLD filename, so `import ... with { type: 'file' }`
threw at module-load time inside the spawned server, the server never
became ready, and the e2e harness timed out at 30s × 4 tests = ~2min.

Fix: re-ran `bun run build:admin-embedded` (scripts/build-admin-embedded.ts)
which regenerates src/admin-embedded.ts from the current dist/ contents.
Manifest now references index-DFgMZhBE.js. All 4 admin-embed-spawn.serial
tests pass locally.

Forward-looking note: the build:admin npm script chains
`cd admin && bun run build && cd .. && bun run scripts/build-admin-embedded.ts`
so regenerating both together is the standard path — the prior commit
manually invoked `vite build` inside admin/ and skipped the second step.

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

* chore: re-bump v0.38.2.0 → v0.38.1.0 (correct patch sequence)

User direction: this is v0.38.1.0 — the first patch on v0.38.0.0, not 0.38.2.0.
v0.38.2.0 was mid-wave when master merged in d0d0e2a6 and I chose the next
slot up; in retrospect 0.38.1.0 is the correct next-patch number since
nothing actually shipped at 0.38.0.0 yet (the PR's been re-iterating through
CI green and the wave is one continuous ship).

Updated:
  - VERSION: 0.38.2.0 → 0.38.1.0
  - package.json: 0.38.2.0 → 0.38.1.0
  - CHANGELOG.md: header rewritten

Trio audit: all three say 0.38.1.0.

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

* test: fill v0.38 gap inventory — submit_agent + scope isolation + D5 shim + admin spend

61 new test cases across 4 files closing the load-bearing gaps from the
v0.38 Agents+Minions wave. Also extracts /admin/api/agents/spend SQL into
a named helper so the endpoint and its test share a single source of truth.

Gap inventory + coverage delta:

  | Surface                                    | Before | After  |
  |--------------------------------------------|--------|--------|
  | submit_agent op (binding enforcement)      | 0      | 17     |
  | agent scope NOT implied by admin           | 0      | 9      |
  | D5 v1→v2 read-time shim                    | 0      | 16     |
  | /admin/api/agents/spend endpoint SQL       | 0      | 19     |

test/submit-agent.test.ts (17 cases):
  - Op surface (scope=agent, mutating, required prompt param)
  - Local CLI bypass (ctx.remote=false → invalid_request)
  - OAuth client requirement (missing clientId, unknown client_id)
  - Binding requirement: refuse when agent scope but bound_tools NULL
  - allowed_tools subset enforcement (passes ⊆, refuses outside)
  - allowed_slug_prefixes prefix-match against bound_slug_prefixes
  - bound_max_concurrent cap (refuse at cap, allow below, exclude
    terminal-state jobs, isolate inflight count by client_id)
  - Happy-path: job inserted + audit row written + prompt NEVER logged
  - max_turns capped at 100

test/scope-agent-isolation.test.ts (9 cases) — D13 regression guard:
  - admin does NOT imply agent (the load-bearing security check)
  - admin still implies sources_admin/users_admin/write/read
  - agent does NOT imply anything else (no reverse inheritance)
  - read+write does NOT imply agent (the common legacy shape)
  - explicit admin+agent compound grant satisfies both
  - ALLOWED_SCOPES_LIST sort order pinned (agent between admin and read)

test/subagent-v1-v2-shim.test.ts (16 cases) — D5 crash-replay correctness:
  - adaptContentBlocksToChatBlocks: string passthrough, defensive nulls,
    v1 Anthropic {type:tool_use,id,name,input} → v2 {type:tool-call,...},
    v2 passthrough, v1 tool_result → v2 tool-result with __legacy__
    toolName sentinel, is_error mapping, mixed v1+v2 in same message
    array (mid-upgrade scenario), malformed-block skip
  - loadPriorToolsV2: empty, gbrain_tool_use_id as stable key for v2,
    legacy-prefixed key for v1 rows, status+error preservation, mixed
    v1+v2 side-by-side with both shapes resolving, ORDER BY stability
  - Exposed both helpers on the existing __testing export from subagent.ts

test/admin-agents-spend.test.ts (19 cases) — Slice 4 SQL pinning:
  - Empty results: no clients / clients without agent scope or bindings
  - Include: scope=agent (with or without bindings), bound_tools set
    (with or without scope=agent — covers partial-migration state)
  - Exclude: soft-deleted (deleted_at IS NOT NULL) clients
  - cap_usd_per_day: null when unset, numeric when set
  - spent_cents_today: zero baseline, sum of today, exclude yesterday
    (UTC-day-aligned), client-id isolation
  - pending_cents: sum of pending+non-expired, exclude expired, exclude
    settled
  - inflight_count: only active/waiting/waiting-children subagent jobs;
    exclude shell jobs; client-id isolated
  - ORDER BY client_name ASC pinned for deterministic UI rendering
  - Multi-word scope strings ('read write agent') handled correctly via
    string_to_array
  - End-to-end happy path: all fields populated together

Refactor: extracted the spend SQL from src/commands/serve-http.ts into a
new exported `queryAgentClientSpend(engine)` helper + `AgentClientSpend`
type. The Express handler now delegates (5 lines). Same query, same
result shape, but a single source of truth that both the endpoint and
the test exercise.

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

* test(agents): v0.38 e2e — gateway path + crash-replay across 5 providers

Two new e2e suites driving the v0.38 runSubagentViaGateway path end-to-end
against PGLite. Both filed in TODOS as v0.38.x follow-ups during the cathedral
ship; building them out caught two real load-bearing bugs in subagent.ts that
would have silently broken crash-replay in production.

Bug 1 — messageIdx collision on fresh runs.
runSubagentViaGateway only passed replayState when priorChatMessages.length > 0,
so on a fresh run the gateway loop's messageIdx counter defaulted to 0. The
seed user message already occupies (job_id, message_idx=0), so the first
onAssistantTurn write at idx 0 hit the unique-constraint and the whole job
failed before any tool call. Fix: always pass replayState with nextMessageIdx
set to 1 on fresh runs (after the seed write). Pinned by
test/e2e/subagent-gateway-path.test.ts ("happy path 1-turn" + "write-ordering
invariant").

Bug 2 — onToolCallStart returned the wrong UUID on crash-replay.
The callback generated a fresh candidateId, INSERTed with ON CONFLICT DO
UPDATE, and returned the local candidateId. On replay, the pre-crash row
survives intact with its ORIGINAL gbrain_tool_use_id, so the local candidateId
was wrong. The gateway loop's replayState.priorTools is keyed by the original
UUID; returning the new one made the short-circuit miss and re-execute every
tool call. Fix: RETURNING gbrain_tool_use_id::text AS gbrain_tool_use_id and
read it back; fall through to candidateId only if RETURNING is empty. Pinned
by test/e2e/subagent-crash-replay-multi-provider.test.ts.

Coverage:
- test/e2e/subagent-gateway-path.test.ts: 7 cases. Happy path 1-turn,
  multi-turn with parallel tool calls, write-ordering invariant
  (persist-before-side-effect), gateway returns malformed tool_call shape,
  cancel mid-loop, capability refusal at submit.
- test/e2e/subagent-crash-replay-multi-provider.test.ts: 13 cases. Five
  provider rows (anthropic / openai / google / openrouter / deepseek) ×
  pre-crash run + replay assertion, plus ordinal-collision PK guard,
  pending-tool short-circuit, v1→v2 shim round-trip.

Both files run hermetically against PGLite (no DATABASE_URL needed) and
use the __setChatTransportForTests gateway seam for stubbed provider
responses. Reset path goes through resetPgliteState + setConfig version=84
so MinionQueue.ensureSchema() sees the migration ledger correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:10:20 -07:00

513 lines
21 KiB
TypeScript

/**
* E2E: SIGKILL crash-replay reconciliation across the provider matrix.
*
* This is the LOAD-BEARING test the v0.38 CEO + Codex reviews called out
* as CRITICAL before ship. The contract:
*
* A subagent job whose worker crashes mid-tool-dispatch MUST reconcile
* correctly on the next worker claim. Specifically:
* 1. Tool executions marked status='complete' (or 'failed') in the DB
* before the crash MUST NOT be re-executed.
* 2. The reconciliation key MUST work across provider response shapes
* — the gbrain-owned stable key (ordinal + gbrain_tool_use_id from
* migration v81) is the canonical key, not the provider tool_use_id.
* 3. Legacy v1 rows (pre-v0.38, ordinal=NULL, gbrain_tool_use_id=NULL)
* get a synthesized stable key via the D5 read-time shim and replay
* the same way.
*
* We don't actually SIGKILL a subprocess (heavy, slow, flaky in CI). Instead
* we SIMULATE the crashed state by pre-seeding subagent_messages +
* subagent_tool_executions in the shape the DB would have post-crash, then
* invoke the handler and assert it reconciles correctly without
* re-executing the prior tools.
*
* Per-provider matrix: gateway.chat() abstracts providers through the
* Vercel AI SDK, but each provider returns slightly different response
* shapes (provider id, finishReason mapping, usage field names, content
* block ordering). We stub the second-turn response with provider-specific
* shapes to prove the reconciler handles all five without leaking
* provider-specific assumptions.
*
* Plan reference: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
* (Risk register row "Stable-ID INSERT race across replays" + Slice 1
* verification step 4 "SIGKILL worker mid-call").
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } 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, ContentBlock } from '../../src/core/minions/types.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
type ChatBlock,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
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' },
});
});
afterEach(() => {
__setChatTransportForTests(null);
});
afterAll(() => {
resetGateway();
});
/**
* Provider matrix. Each entry is one provider whose response shape the
* gateway path must reconcile across. The gateway.chat() normalizer
* collapses these to ChatBlock[] before they hit our toolLoop, but the
* test stubs the normalizer's output to verify the loop downstream
* doesn't leak any provider-specific assumption.
*/
type ProviderShape = {
providerId: string;
modelId: string;
// The second-turn (post-replay) response.
finalResponse: ChatResult;
};
const PROVIDER_MATRIX: ProviderShape[] = [
{
providerId: 'anthropic',
modelId: 'anthropic:claude-sonnet-4-6',
finalResponse: {
text: 'anthropic resumed: search result was helpful',
blocks: [{ type: 'text', text: 'anthropic resumed: search result was helpful' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 50, output_tokens: 8, cache_read_tokens: 30, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
},
},
{
providerId: 'openai',
modelId: 'openai:gpt-5.2',
finalResponse: {
text: 'openai resumed: synthesized answer from prior tool result',
blocks: [{ type: 'text', text: 'openai resumed: synthesized answer from prior tool result' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 45, output_tokens: 10, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-5.2',
providerId: 'openai',
},
},
{
providerId: 'google',
modelId: 'google:gemini-1.5-pro',
finalResponse: {
text: 'gemini resumed: 1M-context replay went fine',
blocks: [{ type: 'text', text: 'gemini resumed: 1M-context replay went fine' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 80, output_tokens: 6, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'google:gemini-1.5-pro',
providerId: 'google',
},
},
{
providerId: 'openrouter',
modelId: 'openrouter:anthropic/claude-sonnet-4-6',
finalResponse: {
text: 'openrouter resumed: proxied claude response',
blocks: [{ type: 'text', text: 'openrouter resumed: proxied claude response' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 50, output_tokens: 7, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openrouter:anthropic/claude-sonnet-4-6',
providerId: 'openrouter',
},
},
{
providerId: 'deepseek',
modelId: 'deepseek:deepseek-chat',
// Representative openai-compatible recipe with native chat + tools.
// (LiteLLM proxy was the original 5th slot in the plan but its recipe
// declares no chat touchpoint — it's embedding-only. Deepseek is the
// matching openai-compatible chat provider with tool calling.)
finalResponse: {
text: 'deepseek resumed: openai-compatible chat works',
blocks: [{ type: 'text', text: 'deepseek resumed: openai-compatible chat works' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 40, output_tokens: 9, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'deepseek:deepseek-chat',
providerId: 'deepseek',
},
},
];
/**
* Stub tool registry that records every execution. Tests assert
* `executions.length === 0` to prove replay short-circuit.
*/
function makeStubTools(executions: Array<{ name: string; input: unknown }>): ToolDef[] {
return [
{
name: 'search',
description: 'stub search',
input_schema: { type: 'object' },
idempotent: true,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'search', input });
return { results: [{ slug: 'wiki/foo' }] };
},
},
];
}
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,
});
}
/**
* Seed a "crashed-mid-loop" state for jobId:
* - 1 user message at idx 0 (the seed prompt)
* - 1 assistant message at idx 1 containing a tool-call block
* - 1 subagent_tool_executions row with status='complete' + the result
* the crashed worker had ALREADY written before SIGKILL
*
* `shape` controls whether the rows are v1 (pre-v0.38: Anthropic content
* blocks, ordinal=NULL, gbrain_tool_use_id=NULL) or v2 (post-v0.38:
* ChatBlock content, ordinal+gbrain_tool_use_id populated).
*/
async function seedCrashedState(
prompt: string,
shape: 'v1' | 'v2',
): Promise<{ jobId: number; toolUseId: string; gbrainId: string | null }> {
const jobRows = 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 })],
);
const jobId = jobRows[0].id;
// Seed user message at idx 0.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_create, model)
VALUES ($1, 0, 'user', $2::jsonb, NULL, NULL, NULL, NULL, NULL)`,
[
jobId,
JSON.stringify(shape === 'v1'
? [{ type: 'text', text: prompt }]
: [{ type: 'text', text: prompt }]),
],
);
// Seed assistant message at idx 1 with a tool-call block.
const toolUseId = shape === 'v1' ? 'toolu_v1_crashed' : 'provider-tc-v2-crashed';
const assistantBlocks: ContentBlock[] = shape === 'v1'
? [{ type: 'tool_use', id: toolUseId, name: 'search', input: { q: 'foo' } }]
: [{ type: 'tool-call' as any, toolCallId: toolUseId, toolName: 'search', input: { q: 'foo' } } as any];
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_create, model)
VALUES ($1, 1, 'assistant', $2::jsonb, 10, 5, 0, 0, 'anthropic:claude-sonnet-4-6')`,
[jobId, JSON.stringify(assistantBlocks)],
);
// Seed the tool execution row. The crashed worker completed the tool
// and persisted the result, but crashed before persisting the next
// user message (the tool_result wrapper). Replay must see this as done.
const priorOutput = JSON.stringify({ results: ['prior'] });
let gbrainId: string | null = null;
if (shape === 'v2') {
gbrainId = '01987654-3210-7000-8000-aaaaaaaaaaaa';
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id, output)
VALUES ($1, 1, $2, 'search', '{}'::jsonb, 'complete',
2, 0, $3::uuid, $4::jsonb)`,
[jobId, toolUseId, gbrainId, priorOutput],
);
} else {
// v1 row: no ordinal, no gbrain_tool_use_id.
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, output)
VALUES ($1, 1, $2, 'search', '{}'::jsonb, 'complete',
1, $3::jsonb)`,
[jobId, toolUseId, priorOutput],
);
}
return { jobId, toolUseId, gbrainId };
}
async function makeCrashedCtx(jobId: number, prompt: string, modelId: string): Promise<MinionJobContext> {
const abortCtrl = new AbortController();
const shutdownCtrl = new AbortController();
return {
id: jobId,
name: 'subagent',
data: { prompt, model: modelId },
attempts_made: 1, // crashed once
signal: abortCtrl.signal,
shutdownSignal: shutdownCtrl.signal,
updateProgress: async () => {},
updateTokens: async () => {},
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
}
// ── Tests ───────────────────────────────────────────────────
describe('SIGKILL crash-replay reconciliation across provider matrix (v0.38 LOAD-BEARING)', () => {
describe.each(PROVIDER_MATRIX)('provider $providerId', (provider) => {
it('replay short-circuits the prior complete tool (v2 shape, gbrain_tool_use_id key)', async () => {
// Stub the SECOND turn — replay should NOT call gateway.chat() for
// turn 1 (the tool dispatch already happened pre-crash). It should
// immediately re-feed the tool_result and ask the LLM for the final
// text answer.
__setChatTransportForTests(async () => provider.finalResponse);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId } = await seedCrashedState('find foo', 'v2');
const ctx = await makeCrashedCtx(jobId, 'find foo', provider.modelId);
const result = await handler(ctx);
// LOAD-BEARING: prior tool MUST NOT re-execute.
expect(executions.length).toBe(0);
// Final result comes from the stubbed second turn.
expect(result.result).toBe(provider.finalResponse.text);
expect(result.stop_reason).toBe('end_turn');
// The prior complete tool row is still status='complete' (not overwritten).
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRows.length).toBe(1);
expect(toolRows[0].status).toBe('complete');
});
it('replay short-circuits the prior complete tool (v1 legacy shape, D5 synthesized key)', async () => {
__setChatTransportForTests(async () => provider.finalResponse);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId } = await seedCrashedState('find foo (v1)', 'v1');
const ctx = await makeCrashedCtx(jobId, 'find foo (v1)', provider.modelId);
const result = await handler(ctx);
// LOAD-BEARING: v1 legacy rows reconcile via D5 synthesized stable
// key — the prior tool MUST NOT re-execute even though it predates
// the gbrain_tool_use_id column.
expect(executions.length).toBe(0);
expect(result.result).toBe(provider.finalResponse.text);
expect(result.stop_reason).toBe('end_turn');
});
});
describe('non-idempotent tool with pending status (unrecoverable error)', () => {
it('throws unrecoverable when a non-idempotent tool is pending on resume', async () => {
// Resume stub re-emits the SAME tool call the worker crashed on.
// The gateway loop assigns the same (job_id, message_idx, ordinal)
// key, the existing row (status=pending) is read back via RETURNING,
// and the priorTools map lookup hits with status='pending'. Since the
// tool is non-idempotent, the loop throws unrecoverable rather than
// re-execute and risk a double side-effect.
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc-pending', toolName: 'put_page', input: { slug: 'foo' } },
] 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));
const tools: ToolDef[] = [{
name: 'put_page',
description: 'non-idempotent stub',
input_schema: { type: 'object' },
idempotent: false,
async execute() { throw new Error('should not be called on replay'); },
}];
const handler = buildHandler(tools);
// Seed: crashed mid-loop with a pending non-idempotent tool exec.
// The user prompt is at idx 0 (the seed write subagent.ts does).
// The crashed worker started executing put_page at message_idx=2
// (which is the NEW assistant turn the resume will generate),
// ordinal=0. priorTools must surface this as status='pending'.
const jobRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = jobRows[0].id;
const gbrainId = '01987654-3210-7000-8000-bbbbbbbbbbbb';
// Just the user prompt — no prior assistant turn, so the resume
// generates the first assistant turn fresh at message_idx=1, and
// the tool dispatch at ordinal=0 will hit the pre-seeded pending row.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, model)
VALUES ($1, 0, 'user', '[{"type":"text","text":"do it"}]'::jsonb, NULL)`,
[jobId],
);
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id)
VALUES ($1, 1, 'tc-pending', 'put_page', '{}'::jsonb, 'pending',
2, 0, $2::uuid)`,
[jobId, gbrainId],
);
const ctx = await makeCrashedCtx(jobId, 'do it', 'anthropic:claude-sonnet-4-6');
// The gateway-loop throws "non-idempotent ... pending on resume; cannot safely re-run".
// The subagent.ts handler doesn't catch this — it bubbles. Asserting it bubbles
// out as an Error is the contract; an UnrecoverableError variant would be a future
// upgrade.
await expect(handler(ctx)).rejects.toThrow(/non-idempotent.*pending/i);
});
});
describe('failed tool on prior turn — replay surfaces the error to the LLM', () => {
it('prior failed tool replays as is_error result, loop completes', async () => {
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const jobRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = jobRows[0].id;
const gbrainId = '01987654-3210-7000-8000-cccccccccccc';
// User msg + assistant turn with tool_use + failed tool row.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, model)
VALUES ($1, 0, 'user', '[{"type":"text","text":"try"}]'::jsonb, NULL),
($1, 1, 'assistant', $2::jsonb, 'anthropic:claude-sonnet-4-6')`,
[
jobId,
JSON.stringify([{ type: 'tool-call', toolCallId: 'tc-failed', toolName: 'search', input: {} }]),
],
);
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, error,
schema_version, ordinal, gbrain_tool_use_id)
VALUES ($1, 1, 'tc-failed', 'search', '{}'::jsonb, 'failed', 'rate limited',
2, 0, $2::uuid)`,
[jobId, gbrainId],
);
// Second turn: LLM acknowledges the failure and ends.
__setChatTransportForTests(async () => ({
text: 'I see search failed (rate limited). Aborting.',
blocks: [{ type: 'text', text: 'I see search failed (rate limited). Aborting.' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 30, output_tokens: 11, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const ctx = await makeCrashedCtx(jobId, 'try', 'anthropic:claude-sonnet-4-6');
const result = await handler(ctx);
expect(result.result).toContain('search failed');
expect(result.stop_reason).toBe('end_turn');
// The prior failed row stays failed (not overwritten).
const finalRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(finalRows[0].status).toBe('failed');
expect(finalRows[0].error).toBe('rate limited');
});
});
describe('reconciliation key uniqueness — concurrent replays don\'t double-insert', () => {
it('two simultaneous replay attempts both see the same prior tool outcome (idempotent reconciliation)', async () => {
// Simulates: worker A crashed → worker B picks up the job → worker C
// also tries to pick it up (lock-contention edge). Both must reconcile
// to the SAME stable key and skip the prior tool execution.
const { jobId } = await seedCrashedState('concurrent replay', 'v2');
const transport = async () => PROVIDER_MATRIX[0].finalResponse;
__setChatTransportForTests(transport);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
// Run handler twice in parallel against the same job. Real workers
// would use the queue's lock to serialize, but the reconciler MUST
// be safe even under spurious double-invocation.
const handler1 = buildHandler(tools);
const handler2 = buildHandler(tools);
const ctx1 = await makeCrashedCtx(jobId, 'concurrent replay', 'anthropic:claude-sonnet-4-6');
const ctx2 = await makeCrashedCtx(jobId, 'concurrent replay', 'anthropic:claude-sonnet-4-6');
const [result1, result2] = await Promise.all([handler1(ctx1), handler2(ctx2)]);
// Neither path re-executed the prior tool.
expect(executions.length).toBe(0);
expect(result1.result).toBe(PROVIDER_MATRIX[0].finalResponse.text);
expect(result2.result).toBe(PROVIDER_MATRIX[0].finalResponse.text);
// Only one prior tool exec row exists (no duplicate inserts).
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT COUNT(*)::int AS n FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(Number(toolRows[0].n)).toBe(1);
});
});
});