fix(3591): forward workstream to native registry dispatch in GSDToolsRuntime

createGSDToolsRuntime accepted opts.workstream and forwarded it to the
QuerySubprocessAdapter (line 38) but the QueryNativeDirectAdapter's
dispatch closure dropped it:

  dispatch: (registryCommand, registryArgs) =>
    registry.dispatch(registryCommand, registryArgs, opts.projectDir)

`registry.dispatch(command, args, projectDir, workstream?)` accepts a
4th workstream argument and forwards it to handlers. When a GSDTools
instance was created with a workstream, the native fast-path silently
routed planning-path queries to the root `.planning/` tree instead of
`.planning/workstreams/<name>/`. Subprocess dispatch correctly carried
the workstream; native dispatch did not — runtime-bridge mode parity
broke for any workstream-aware GSDTools consumer using the native path.

One-line fix: pass opts.workstream as the 4th arg to registry.dispatch.

Regression test exercises three paths:

  1. Constructor-seam unit test: spy on QueryNativeDirectAdapter,
     capture the dispatch closure, verify it reaches a registry that
     reports the unknown-command error message.
  2. Back-compat: same with workstream omitted — closure still reaches
     the registry.
  3. End-to-end: spy on createRegistry to inject a probe registry with a
     registered handler that records its args. Invoke through
     runtime.bridge.dispatchHotpath(). Assert the handler observed
     workstream='frontend-ws' as its 3rd arg.

RED verified: end-to-end probe fails on pre-fix tree with
`expected undefined to be 'frontend-ws'`. GREEN after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 22:35:13 -04:00
parent 823b4ece0a
commit 4824aefe42
3 changed files with 193 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
issue: 3591
---
**`createGSDToolsRuntime` now forwards the workstream to native registry dispatch** — the closure passed to `QueryNativeDirectAdapter.dispatch` previously called `registry.dispatch(command, args, projectDir)` and silently dropped `opts.workstream`. As a result, GSDTools-native query handlers ran against the root `.planning/` tree even when the GSDTools instance was created with a `workstream`. The closure now passes `opts.workstream` as the 4th argument, matching the `QuerySubprocessAdapter` path that already forwards it. Every native dispatch through the runtime bridge now routes planning-path queries to `.planning/workstreams/<name>/` when a workstream is set.

View File

@@ -0,0 +1,181 @@
/**
* Bug #3591: createGSDToolsRuntime accepts a `workstream` option, but the
* native dispatch closure passed to QueryNativeDirectAdapter dropped it
* before forwarding to registry.dispatch(). The omission silently routed
* native query handlers to root `.planning` instead of
* `.planning/workstreams/<name>` whenever a GSDTools instance was created
* with a workstream and native dispatch was used.
*
* The fix passes `opts.workstream` as the 4th argument to
* `registry.dispatch(command, args, projectDir, workstream)`. This test
* captures the dispatch closure via a constructor-seam spy on
* QueryNativeDirectAdapter, builds a mock registry whose dispatch records
* its arguments, then invokes the captured closure to verify the
* workstream is forwarded.
*/
import { describe, it, expect, vi } from 'vitest';
import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js';
import * as adapterModule from './query-native-direct-adapter.js';
describe('bug #3591: createGSDToolsRuntime forwards workstream to registry.dispatch', () => {
it('native dispatch closure passes opts.workstream as 4th arg to registry.dispatch', async () => {
// Capture the `dispatch` option passed into QueryNativeDirectAdapter.
let capturedDispatch:
| ((command: string, args: string[]) => Promise<unknown>)
| null = null;
const adapterSpy = vi
.spyOn(adapterModule, 'QueryNativeDirectAdapter')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation((deps: any) => {
capturedDispatch = deps.dispatch;
// Return a minimal stub satisfying the runtime constructor.
return {
dispatchResult: vi.fn(),
dispatchJson: vi.fn(),
dispatchRaw: vi.fn(),
} as unknown as adapterModule.QueryNativeDirectAdapter;
});
try {
createGSDToolsRuntime({
projectDir: '/tmp/3591-proj',
gsdToolsPath: '/tmp/gsd-tools.cjs',
timeoutMs: 1_000,
workstream: 'frontend-ws',
shouldUseNativeQuery: () => true,
execJsonFallback: vi.fn(async () => ({})),
execRawFallback: vi.fn(async () => ''),
});
expect(adapterSpy).toHaveBeenCalled();
expect(capturedDispatch).not.toBeNull();
} finally {
adapterSpy.mockRestore();
}
// The captured closure must call registry.dispatch with four args:
// (registryCommand, registryArgs, projectDir, workstream).
//
// We can't easily intercept the *real* registry created inside
// createGSDToolsRuntime, so we exercise the closure shape: the dispatch
// function from createGSDToolsRuntime is bound to a real registry that
// throws GSDError for unknown commands. Calling it with an unknown
// command MUST reach the registry — which proves the closure is wired —
// and we then verify the projectDir/workstream are passed by inspecting
// a `register`d probe handler.
//
// For a tighter assertion, swap in a spied registry via a second
// createGSDToolsRuntime call that re-uses the captured `dispatch`
// closure's structure: we re-build the closure inline and verify
// structurally.
//
// (The end-to-end "real registry, real handler" path is exercised by
// the existing query-runtime-seam-coverage.test.ts; here we focus on
// the closure-arg-forwarding contract.)
//
// Drive the closure with a unique unknown command name and assert the
// resulting GSDError mentions that command — proving the closure
// forwarded to a real registry.
let thrownMessage: string | null = null;
try {
await capturedDispatch!('__bug-3591-unknown-cmd__', ['x']);
} catch (err) {
thrownMessage = err instanceof Error ? err.message : String(err);
}
expect(thrownMessage).toMatch(/__bug-3591-unknown-cmd__/);
});
it('forwards undefined workstream when the option is omitted (back-compat)', async () => {
// Same shape as above but no workstream. The closure must still pass
// projectDir; passing `undefined` for the 4th slot is the documented
// signature of registry.dispatch.
let capturedDispatch:
| ((command: string, args: string[]) => Promise<unknown>)
| null = null;
const adapterSpy = vi
.spyOn(adapterModule, 'QueryNativeDirectAdapter')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation((deps: any) => {
capturedDispatch = deps.dispatch;
return {
dispatchResult: vi.fn(),
dispatchJson: vi.fn(),
dispatchRaw: vi.fn(),
} as unknown as adapterModule.QueryNativeDirectAdapter;
});
try {
createGSDToolsRuntime({
projectDir: '/tmp/3591-proj',
gsdToolsPath: '/tmp/gsd-tools.cjs',
timeoutMs: 1_000,
// workstream intentionally omitted
shouldUseNativeQuery: () => true,
execJsonFallback: vi.fn(async () => ({})),
execRawFallback: vi.fn(async () => ''),
});
expect(capturedDispatch).not.toBeNull();
} finally {
adapterSpy.mockRestore();
}
let thrownMessage: string | null = null;
try {
await capturedDispatch!('__bug-3591-unknown-cmd-2__', []);
} catch (err) {
thrownMessage = err instanceof Error ? err.message : String(err);
}
expect(thrownMessage).toMatch(/__bug-3591-unknown-cmd-2__/);
});
});
describe('bug #3591: end-to-end — workstream-aware probe handler receives the workstream', () => {
it('a registered probe handler receives opts.workstream as its 3rd arg', async () => {
// End-to-end path: register a probe handler on a real registry (via
// module-level export), build the runtime with a workstream, and
// assert the handler observed the workstream when invoked through the
// native dispatch closure.
const registryModule = await import('./query/index.js');
const probeRegistry = registryModule.createRegistry();
const seen: Array<{ args: string[]; projectDir: string; workstream?: string }> = [];
probeRegistry.register('__bug-3591-probe__', async (args, projectDir, workstream) => {
seen.push({ args, projectDir, workstream });
return { data: { ok: true } };
});
// The runtime builds its OWN registry internally; we can't substitute
// ours unless we mock createRegistry. Hoist a module mock for that.
const createRegistrySpy = vi
.spyOn(registryModule, 'createRegistry')
.mockReturnValue(probeRegistry);
try {
const runtime = createGSDToolsRuntime({
projectDir: '/tmp/3591-proj',
gsdToolsPath: '/tmp/gsd-tools.cjs',
timeoutMs: 1_000,
workstream: 'frontend-ws',
shouldUseNativeQuery: () => true,
execJsonFallback: vi.fn(async () => ({})),
execRawFallback: vi.fn(async () => ''),
});
await runtime.bridge.dispatchHotpath(
'__bug-3591-probe-legacy__',
[],
'__bug-3591-probe__',
['payload'],
'json',
);
} finally {
createRegistrySpy.mockRestore();
}
expect(seen).toHaveLength(1);
expect(seen[0]?.args).toEqual(['payload']);
expect(seen[0]?.projectDir).toBe('/tmp/3591-proj');
expect(seen[0]?.workstream).toBe('frontend-ws');
});
});

View File

@@ -43,7 +43,13 @@ export function createGSDToolsRuntime(opts: {
const nativeDirectAdapter = new QueryNativeDirectAdapter({
timeoutMs: opts.timeoutMs,
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir),
// #3591: forward opts.workstream to the registry so native dispatch
// routes planning-path queries to .planning/workstreams/<name>/
// instead of the root .planning tree. createGSDToolsRuntime accepts
// workstream and the QuerySubprocessAdapter already forwards it
// (line 38); the native dispatch closure was the only seam that
// dropped it, silently routing GSDTools-native queries to root.
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir, opts.workstream),
...nativeErrorFactory,
});