refactor(#190): collapse SDK sync bridge and remove synckit seam

This commit is contained in:
Tom Boucher
2026-05-25 10:30:34 -04:00
parent d2634bf570
commit 4fa13cf9de
20 changed files with 72 additions and 1992 deletions

28
sdk/package-lock.json generated
View File

@@ -10,7 +10,6 @@
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
"synckit": "^0.11.12",
"ws": "8.20.1"
},
"bin": {
@@ -803,18 +802,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
@@ -1707,21 +1694,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View File

@@ -45,7 +45,6 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
"synckit": "^0.11.12",
"ws": "8.20.1"
},
"devDependencies": {

View File

@@ -1,164 +0,0 @@
/**
* Pinning tests for the executeForCjs synchronous primitive.
*
* Covers:
* - Success path: known read-only command returns { ok: true, data, exitCode: 0 }
* - unknown_command: unknown command key returns { ok: false, errorKind: 'unknown_command' }
* - native_failure: handler that throws a generic Error returns { ok: false, errorKind: 'native_failure' }
* - internal_error mapping tracked as a TODO until a deterministic TypeError fixture exists
* - Idempotency: calling twice with identical input produces identical output
* - Sync nature: returned value is not a Promise
*/
import { describe, it, expect, beforeAll } from 'vitest';
import type { RuntimeBridgeSyncResult } from './index.js';
// We import after build — the test runner loads the TS via tsx/vitest,
// but executeForCjs creates a Worker which loads the compiled worker.js.
// So we must build before running these tests. In CI, build runs first.
// In local dev, run `npm run build` before vitest.
let executeForCjs: (input: import('./index.js').ExecuteForCjsInput) => RuntimeBridgeSyncResult;
beforeAll(async () => {
// Dynamic import so we get an actionable error if the module is missing
// (RED phase: will fail here with "Cannot find module")
const mod = await import('./index.js');
executeForCjs = mod.executeForCjs;
});
describe('executeForCjs - sync primitive', () => {
it('returns a non-Promise object synchronously', () => {
const result = executeForCjs({
registryCommand: 'generate-slug',
registryArgs: ['My Phase'],
legacyCommand: 'generate-slug',
legacyArgs: ['My Phase'],
mode: 'json',
projectDir: '/tmp',
});
// Must NOT be a Promise
expect(result).not.toBeInstanceOf(Promise);
expect(typeof result).toBe('object');
// .ok must be accessible synchronously
expect('ok' in result).toBe(true);
});
it('success: generate-slug returns ok:true with data and exitCode:0', () => {
const result = executeForCjs({
registryCommand: 'generate-slug',
registryArgs: ['My Phase'],
legacyCommand: 'generate-slug',
legacyArgs: ['My Phase'],
mode: 'json',
projectDir: '/tmp',
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Expected ok:true');
expect(result.exitCode).toBe(0);
expect(result.data).toBeDefined();
// generate-slug returns { slug: 'my-phase' }
expect((result.data as Record<string, unknown>).slug).toBe('my-phase');
});
it('unknown_command: returns ok:false with errorKind unknown_command', () => {
const result = executeForCjs({
registryCommand: '__nonexistent_command_xyz__',
registryArgs: [],
legacyCommand: '__nonexistent_command_xyz__',
legacyArgs: [],
mode: 'json',
projectDir: '/tmp',
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error('Expected ok:false');
expect(result.errorKind).toBe('unknown_command');
expect(result.exitCode).not.toBe(0);
});
it('native_failure: handler execution failure is classified as native_failure', () => {
// generate-slug with no args throws a GSDError (validation) — that maps to validation_error.
// We need a command that throws a plain Error (GSDToolsError classification.kind='failure').
//
// Phase 5.1 fix note: the Phase 5.0 fixture used projectDir='/tmp' with an absolute
// path arg that started with /tmp — after the worker fix threads projectDir correctly,
// frontmatter.get returns a soft ok:true error instead of throwing (path escape check
// passes, then realpath on the nonexistent path returns ok:true with error field).
// Updated fixture: use a completely nonexistent projectDir so resolvePathUnderProject
// calls realpath('/nonexistent...') and throws ENOENT, which is classified as native_failure.
const result = executeForCjs({
registryCommand: 'frontmatter.get',
registryArgs: ['file.md'],
legacyCommand: 'frontmatter get',
legacyArgs: ['file.md'],
mode: 'json',
projectDir: '/nonexistent-absolutely-does-not-exist-project-dir',
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error('Expected ok:false');
expect(result.errorKind).toBe('native_failure');
expect(result.exitCode).not.toBe(0);
});
it.todo('internal_error: requires fixture command that throws TypeError');
it('unknown_command: unregistered command is classified as unknown_command', () => {
const result = executeForCjs({
registryCommand: '__nonexistent_xyz__',
registryArgs: [],
legacyCommand: '__nonexistent_xyz__',
legacyArgs: [],
mode: 'json',
projectDir: '/tmp',
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error('Expected ok:false');
expect(result.errorKind).toBe('unknown_command');
});
it('idempotency: calling twice with identical input returns identical output', () => {
const input = {
registryCommand: 'generate-slug',
registryArgs: ['Idempotency Test'],
legacyCommand: 'generate-slug',
legacyArgs: ['Idempotency Test'],
mode: 'json' as const,
projectDir: '/tmp',
};
const result1 = executeForCjs(input);
const result2 = executeForCjs(input);
expect(result1.ok).toBe(result2.ok);
expect(result1.exitCode).toBe(result2.exitCode);
if (result1.ok && result2.ok) {
expect(JSON.stringify(result1.data)).toBe(JSON.stringify(result2.data));
}
});
it('idempotency: unknown command returns same errorKind on repeat calls', () => {
const input = {
registryCommand: '__idempotency_test_unknown__',
registryArgs: [],
legacyCommand: '__idempotency_test_unknown__',
legacyArgs: [],
mode: 'json' as const,
projectDir: '/tmp',
};
const result1 = executeForCjs(input);
const result2 = executeForCjs(input);
expect(result1.ok).toBe(false);
expect(result2.ok).toBe(false);
if (!result1.ok && !result2.ok) {
expect(result1.errorKind).toBe(result2.errorKind);
expect(result1.exitCode).toBe(result2.exitCode);
}
});
});

View File

@@ -1,154 +0,0 @@
/**
* executeForCjs — synchronous SDK runtime bridge primitive.
*
* Provides a synchronous `executeForCjs()` function that CJS callers can use
* to invoke any registered SDK command without dealing with async/await or
* top-level-await restrictions that prevent CJS from using the async bridge.
*
* ## Mechanism
*
* Uses `synckit` (Atomics.wait + SharedArrayBuffer + worker_threads) to run
* the async `QueryRuntimeBridge.execute()` in a worker thread and block the
* calling thread until the result is available. The worker is spawned lazily
* on first call and reused for all subsequent calls.
*
* ## Worker overhead
*
* - First call (worker startup + native bridge construction): ~80 ms.
* - Subsequent calls (steady state): ~0.1 ms per call (excluding handler work).
*
* ## CRITICAL: Do NOT call from an async context
*
* `executeForCjs` uses `Atomics.wait` under the hood, which **blocks the
* calling thread**. Calling it from inside an async function that is itself
* running on the Node.js main thread event loop will **deadlock** because
* the event loop cannot process the worker's response message while blocked.
*
* Safe callers:
* - CJS modules evaluated at require-time (synchronous module initialisation).
* - Worker threads that are not using the event loop.
*
* Unsafe callers:
* - Any `async function` on the main thread.
* - Anything inside a `Promise` callback on the main thread.
*
* ## CJS consumption
*
* ```js
* const { executeForCjs } = require('@opengsd/gsd-sdk/dist/runtime-bridge-sync/index.js');
* const result = executeForCjs({ registryCommand: 'generate-slug', registryArgs: ['My Phase'], ... });
* if (result.ok) console.log(result.data); // { slug: 'my-phase' }
* ```
*
* @module runtime-bridge-sync
*/
import { createSyncFn } from 'synckit';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import type { RuntimeBridgeExecuteInput } from '../query-runtime-bridge.js';
// Re-export the input type so callers can import it alongside executeForCjs
export type { RuntimeBridgeExecuteInput };
// Convenience alias for callers who prefer a shorter name
export type ExecuteForCjsInput = RuntimeBridgeExecuteInput;
// ─── Result type ─────────────────────────────────────────────────────────────
/**
* The 6 canonical error kinds from ADR-0001 Dispatch Policy Module.
*/
export type SyncErrorKind =
| 'unknown_command'
| 'native_failure'
| 'native_timeout'
| 'fallback_failure'
| 'validation_error'
| 'internal_error';
/**
* Discriminated union returned by `executeForCjs`.
*
* - `ok: true` — command executed successfully. `data` is the handler's return value.
* - `ok: false` — command failed. `errorKind` identifies the failure category.
*/
export type RuntimeBridgeSyncResult =
| { ok: true; data: unknown; exitCode: 0 }
| {
ok: false;
exitCode: number;
errorKind: SyncErrorKind;
errorDetails?: unknown;
stderrLines: string[];
};
// ─── Lazy sync-fn factory ──────────────────────────────────────────────────
// Resolve worker path to the compiled dist artifact.
//
// The worker MUST be the compiled JS file (dist/runtime-bridge-sync/worker.js),
// NOT the TypeScript source. This is because:
// 1. synckit spawns the worker via Node.js directly (no tsx transform).
// 2. When vitest runs tests from the TS source tree, import.meta.url points to
// src/, not dist/. We must redirect to dist/ in all cases.
//
// Strategy: navigate from the current file's directory to the package root,
// then resolve to dist/runtime-bridge-sync/worker.js.
// The current file lives either in:
// src/runtime-bridge-sync/ (vitest TS source context)
// dist/runtime-bridge-sync/ (compiled CJS/ESM consumer context)
// In both cases, two levels up is the package root (sdk/).
const _moduleUrl = import.meta.url;
function resolveWorkerPath(): string {
const currentDir = dirname(fileURLToPath(_moduleUrl));
// currentDir is either src/runtime-bridge-sync or dist/runtime-bridge-sync.
// Two levels up is the sdk/ package root.
const pkgRoot = join(currentDir, '..', '..');
return join(pkgRoot, 'dist', 'runtime-bridge-sync', 'worker.js');
}
let _syncFn: ((input: RuntimeBridgeExecuteInput) => RuntimeBridgeSyncResult) | null = null;
function getSyncFn(): (input: RuntimeBridgeExecuteInput) => RuntimeBridgeSyncResult {
if (_syncFn) return _syncFn;
const workerPath = resolveWorkerPath();
_syncFn = createSyncFn<(input: RuntimeBridgeExecuteInput) => Promise<RuntimeBridgeSyncResult>>(
workerPath,
{ timeout: 60_000 },
);
return _syncFn;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Execute a registered SDK command synchronously.
*
* This function blocks the calling thread until the command completes.
* It must NOT be called from an async context on the main event-loop thread
* (see module-level JSDoc for details).
*
* @param input - The command input, matching RuntimeBridgeExecuteInput.
* @returns A RuntimeBridgeSyncResult — either ok:true with data, or ok:false with errorKind.
*
* @example
* ```js
* const { executeForCjs } = require('@opengsd/gsd-sdk/dist/runtime-bridge-sync/index.js');
* const result = executeForCjs({
* registryCommand: 'generate-slug',
* registryArgs: ['My Phase'],
* legacyCommand: 'generate-slug',
* legacyArgs: ['My Phase'],
* mode: 'json',
* projectDir: '/path/to/project',
* });
* if (result.ok) {
* console.log(result.data.slug); // 'my-phase'
* }
* ```
*/
export function executeForCjs(input: RuntimeBridgeExecuteInput): RuntimeBridgeSyncResult {
return getSyncFn()(input);
}

View File

@@ -1,150 +0,0 @@
/**
* Regression test for the Phase 5.0 worker bug: projectDir and workstream were
* dropped from RuntimeBridgeExecuteInput before being forwarded to
* registry.dispatch(). The worker constructed a module-scoped
* QueryNativeDirectAdapter with a hardcoded projectDir='' — meaning any handler
* that reads .planning/ (e.g. state.*) would either fail silently or read from
* the process CWD rather than the requested project directory.
*
* Fix (Phase 5.1): the adapter is now constructed per-request inside
* dispatchNative so request.projectDir and request.workstream close over the
* correct values.
*
* These tests must:
* - FAIL against the unfixed worker (projectDir='', handler sees wrong dir).
* - PASS against the fixed worker (projectDir threaded correctly).
*
* NOTE: executeForCjs uses a compiled dist/ worker (see index.ts comments).
* The tests here call executeForCjs, which requires the worker to be rebuilt
* before the fix is observable. Run `npm run build` in sdk/ first.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { executeForCjs } from './index.js';
// ─── Fixture STATE.md with parseable frontmatter ──────────────────────────
const FIXTURE_STATE = `---
gsd_state_version: 1.0
milestone: v9.1
milestone_name: Regression Test Milestone
status: executing
---
# Project State
## Current Position
Phase: 9 (Regression Tests) — EXECUTING
Plan: 1 of 2
Status: Executing Phase 9
Last activity: 2026-05-15 -- Regression test started
Progress: [█████░░░░░] 50%
`;
// ─── Helpers ───────────────────────────────────────────────────────────────
let tmpDir: string;
beforeAll(async () => {
tmpDir = join(
tmpdir(),
`gsd-projectdir-regression-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'STATE.md'), FIXTURE_STATE, 'utf-8');
});
afterAll(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ─── Tests ─────────────────────────────────────────────────────────────────
describe('executeForCjs projectDir regression (Phase 5.0 bug)', () => {
it('threads projectDir to the handler: state.json returns frontmatter data from the tmpdir fixture', () => {
// This test FAILS against the unfixed worker because projectDir='' causes
// the handler to look for .planning/STATE.md relative to '' (process CWD),
// which does not have a STATE.md fixture. The handler returns { error: 'STATE.md not found' }.
//
// With the fix, projectDir=tmpDir is forwarded and the handler reads the fixture.
const result = executeForCjs({
registryCommand: 'state.json',
registryArgs: [],
legacyCommand: 'state',
legacyArgs: ['json'],
mode: 'json',
projectDir: tmpDir,
});
expect(result.ok).toBe(true);
if (!result.ok) return; // narrow for TS
const data = result.data as Record<string, unknown>;
// The handler should have found the fixture and returned parsed frontmatter.
// Key assertions: these fields come from FIXTURE_STATE and are absent from
// any STATE.md that might exist at ''.
expect(data).not.toHaveProperty('error');
expect(data.milestone).toBe('v9.1');
expect(data.milestone_name).toBe('Regression Test Milestone');
expect(data.status).toBe('executing');
});
it('negative: nonexistent projectDir returns ok:true with {error} (handler-level not-found)', () => {
// A completely nonexistent directory: handler cannot find .planning/STATE.md
// and returns a structured error payload rather than throwing. This is the
// expected "soft failure" shape for state.json on a missing project.
const result = executeForCjs({
registryCommand: 'state.json',
registryArgs: [],
legacyCommand: 'state',
legacyArgs: ['json'],
mode: 'json',
projectDir: '/nonexistent-gsd-project-regression-test-dir',
});
// The handler returns { data: { error: 'STATE.md not found' } } — ok:true
// because it is a domain-level not-found, not a dispatch error.
expect(result.ok).toBe(true);
if (!result.ok) return;
const data = result.data as Record<string, unknown>;
expect(data).toHaveProperty('error');
expect(String(data.error)).toMatch(/STATE\.md not found/i);
});
it('workstream support: GSDTransport routes workstream requests natively (Phase 6 fix)', () => {
// Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped
// requests. The worker's dispatchNative closure (Phase 5.1 fix) correctly
// threads request.workstream through to registry.dispatch(), so native handlers
// route to the workstream-scoped .planning/workstreams/<ws>/ directory.
//
// The workstream 'some-workstream' has no separate STATE.md in tmpDir/
// .planning/workstreams/some-workstream/, so the handler returns a domain-level
// "not found" error (ok:true with {error:...}) — exactly like the nonexistent
// projectDir case. This confirms native dispatch was used (subprocess would
// have returned ok:false / errorKind).
const result = executeForCjs({
registryCommand: 'state.json',
registryArgs: [],
legacyCommand: 'state',
legacyArgs: ['json'],
mode: 'json',
projectDir: tmpDir,
workstream: 'some-workstream',
});
// Native dispatch used → ok:true (handler-level not-found, not a dispatch error).
expect(result.ok).toBe(true);
if (!result.ok) return;
const data = result.data as Record<string, unknown>;
// Domain-level not-found: workstream's STATE.md doesn't exist in the fixture.
expect(data).toHaveProperty('error');
expect(String(data.error)).toMatch(/STATE\.md not found/i);
});
});

View File

@@ -1,224 +0,0 @@
/**
* Synckit worker for executeForCjs.
*
* Loaded by synckit's worker pool. Constructs a native-only QueryRuntimeBridge
* lazily (once per worker lifetime) and handles async execution, projecting
* results into the RuntimeBridgeSyncResult discriminated union.
*
* The bridge is configured with:
* - allowFallbackToSubprocess: false — keeps the worker self-contained with no
* child-process spawning. Unknown commands surface as 'unknown_command' errors.
* - strictSdk: false — lets the transport surface 'unknown_command' rather than
* throwing before dispatch.
*/
import { runAsWorker } from 'synckit';
import { createRegistry } from '../query/index.js';
import { GSDTransport } from '../gsd-transport.js';
import { QueryExecutionPolicy } from '../query-execution-policy.js';
import { QueryNativeDirectAdapter } from '../query-native-direct-adapter.js';
import { QueryNativeHotpathAdapter } from '../query-native-hotpath-adapter.js';
import { QueryRuntimeBridge } from '../query-runtime-bridge.js';
import { GSDToolsError } from '../gsd-tools-error.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { createQueryNativeErrorFactory } from '../query-tools-error-factory.js';
import { formatQueryRawOutput } from '../query-raw-output-projection.js';
import type { RuntimeBridgeExecuteInput } from '../query-runtime-bridge.js';
import type { RuntimeBridgeSyncResult, SyncErrorKind } from './index.js';
// ─── Lazy bridge singleton ──────────────────────────────────────────────────
let bridgeInstance: QueryRuntimeBridge | null = null;
function getBridge(): QueryRuntimeBridge {
if (bridgeInstance) return bridgeInstance;
const registry = createRegistry();
const NATIVE_TIMEOUT_MS = 30_000; // 30 s ceiling for any single handler
const nativeErrorFactory = createQueryNativeErrorFactory(NATIVE_TIMEOUT_MS);
// Build a per-request adapter inside dispatchNative so that projectDir and
// workstream from the request close over the correct values. The Phase 5.0
// bug was a module-scoped adapter that hardcoded projectDir = '' — any
// handler reading .planning/ (e.g. state.*) received an empty path and
// silently failed or read from the process CWD. Constructing per-request
// adds microseconds; correctness wins. (fix for latent bug, Phase 5.1)
const transport = new GSDTransport(registry, {
dispatchNative: (request) => {
const adapter = new QueryNativeDirectAdapter({
timeoutMs: NATIVE_TIMEOUT_MS,
dispatch: (registryCommand, registryArgs) =>
registry.dispatch(registryCommand, registryArgs, request.projectDir, request.workstream),
...nativeErrorFactory,
});
return adapter.dispatchResult(
request.legacyCommand,
request.legacyArgs,
request.registryCommand,
request.registryArgs,
);
},
// #3631: forward raw-mode projection so mode:'raw' returns the per-command
// scalar string (next-decimal token, get-phase section, etc.) instead of
// falling back to generic JSON-stringify. Without this, family-router
// sdkHandlers requesting mode:'raw' under --raw receive a stringified
// JSON IR — the regression #3577 introduced for every family router.
formatNativeRaw: (registryCommand, data) => formatQueryRawOutput(registryCommand, data),
// Subprocess fallback stubs — never called because allowFallbackToSubprocess=false
execSubprocessJson: () =>
Promise.reject(new Error('Subprocess fallback disabled in sync bridge worker')),
execSubprocessRaw: () =>
Promise.reject(new Error('Subprocess fallback disabled in sync bridge worker')),
});
const executionPolicy = new QueryExecutionPolicy(transport);
// Hotpath adapter: construct a stub that satisfies the QueryRuntimeBridge
// constructor. executeForCjs does not invoke dispatchHotpath so this
// adapter is never actually called. We still need a valid instance because
// QueryRuntimeBridge requires one at construction time.
const stubDirectAdapter = new QueryNativeDirectAdapter({
timeoutMs: NATIVE_TIMEOUT_MS,
dispatch: () => Promise.reject(new Error('stub: hotpath direct adapter not used')),
...nativeErrorFactory,
});
const hotpathAdapter = new QueryNativeHotpathAdapter(
() => true,
stubDirectAdapter,
() => Promise.reject(new Error('hotpath json fallback disabled')),
() => Promise.reject(new Error('hotpath raw fallback disabled')),
);
bridgeInstance = new QueryRuntimeBridge(
registry,
executionPolicy,
hotpathAdapter,
() => true, // always prefer native
{
allowFallbackToSubprocess: false,
strictSdk: false,
},
);
return bridgeInstance;
}
// ─── Error classification ───────────────────────────────────────────────────
/**
* Map a caught error into the 6-kind ADR-0001 error taxonomy.
*
* GSDToolsError.classification.kind: 'timeout' | 'failure'
* GSDError.classification: ErrorClassification enum
*
* Mapping:
* - 'Subprocess fallback disabled' message → unknown_command (no native adapter for command)
* - GSDToolsError timeout kind → native_timeout
* - GSDError Validation → validation_error
* - GSDError Blocked → validation_error (semantic: prerequisite missing)
* - TypeError (programming error) → internal_error
* - GSDToolsError failure + TypeError cause → internal_error
* - GSDToolsError failure → native_failure
* - Unknown Error → internal_error
*/
function readReason(error: unknown): string | undefined {
// Handlers can pin a CJS-style ERROR_REASON snake_case code on the GSDError
// they throw (e.g. configGet → 'config_key_not_found'). The worker
// propagates it through errorDetails so the CJS dispatcher can call
// `error(msg, reason)` and `--json-errors` clients see a typed reason
// rather than the generic 'unknown'. (Bugs #2943, #3086.)
if (error && typeof error === 'object' && 'reason' in error) {
const r = (error as { reason?: unknown }).reason;
if (typeof r === 'string' && r.length > 0) return r;
}
return undefined;
}
function classifyError(error: unknown): { kind: SyncErrorKind; exitCode: number; message: string; reason?: string } {
if (error instanceof GSDToolsError) {
const { classification, exitCode, message } = error;
// Unknown command: transport throws 'Subprocess fallback disabled: command ... cannot run without native dispatch'
if (
classification.kind === 'failure' &&
message.includes('Subprocess fallback disabled:') &&
message.includes('cannot run without native dispatch')
) {
return { kind: 'unknown_command', exitCode: exitCode ?? 1, message };
}
if (classification.kind === 'timeout') {
return { kind: 'native_timeout', exitCode: exitCode ?? 1, message };
}
// Unwrap the cause once. The native direct adapter wraps every non-
// GSDToolsError thrown by a handler in a GSDToolsError via
// `createNativeFailureError`, preserving the original via `cause`.
// Classification of validation / blocked errors therefore has to walk
// through to the cause — otherwise every GSDError validation surfaces
// as `native_failure` and callers cannot distinguish "you gave me bad
// input" from "the SDK crashed." (Phase 6 / #3592 contract bug.)
const cause = (error as NodeJS.ErrnoException & { cause?: unknown }).cause;
if (cause instanceof GSDError) {
const reason = readReason(cause);
if (
cause.classification === ErrorClassification.Validation ||
cause.classification === ErrorClassification.Blocked
) {
return { kind: 'validation_error', exitCode: 10, message: cause.message, reason };
}
// Execution-classified GSDError is a 'handler said no' result —
// exitCode 1, internal_error kind for taxonomy purposes, but pass
// the structured reason through so the CJS dispatcher can render
// the proper `--json-errors` shape.
return { kind: 'internal_error', exitCode: 1, message: cause.message, reason };
}
if (cause instanceof TypeError) {
return { kind: 'internal_error', exitCode: exitCode ?? 1, message };
}
return { kind: 'native_failure', exitCode: exitCode ?? 1, message };
}
if (error instanceof GSDError) {
const { classification, message } = error;
const reason = readReason(error);
if (
classification === ErrorClassification.Validation ||
classification === ErrorClassification.Blocked
) {
return { kind: 'validation_error', exitCode: 10, message, reason };
}
return { kind: 'internal_error', exitCode: 1, message, reason };
}
if (error instanceof TypeError) {
const message = error.message;
return { kind: 'internal_error', exitCode: 1, message };
}
const message = error instanceof Error ? error.message : String(error);
return { kind: 'internal_error', exitCode: 1, message };
}
// ─── Worker entry point ─────────────────────────────────────────────────────
runAsWorker(async (input: RuntimeBridgeExecuteInput): Promise<RuntimeBridgeSyncResult> => {
const bridge = getBridge();
try {
const data = await bridge.execute(input);
return { ok: true, data, exitCode: 0 };
} catch (error: unknown) {
const { kind, exitCode, message, reason } = classifyError(error);
const errorDetails: { message: string; reason?: string } = { message };
if (reason) errorDetails.reason = reason;
return {
ok: false,
exitCode,
errorKind: kind,
errorDetails,
stderrLines: [],
};
}
});