diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 13:01:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 13:01:18 +0900 |
| commit | 5630bf177c1f45d8e35ddbe35bf7d5136dc4f244 (patch) | |
| tree | 7603569ef855fe3aa531f7c8bd125dff477eaf79 /packages/transport-http/src | |
| parent | 1eb25dcace8c3cb0b3a3871a74d0dd3eaf169eb7 (diff) | |
| download | dispatch-5630bf177c1f45d8e35ddbe35bf7d5136dc4f244.tar.gz dispatch-5630bf177c1f45d8e35ddbe35bf7d5136dc4f244.zip | |
fix(lsp): broken-server recovery + config source attribution
Two issues found by decompiling the running dispatch-server binary
(handoff from a ruby-lsp setup in raylib-jamstack):
Issue 2 (blocker): a failed LSP server was "broken" FOREVER — the
manager's broken set was cleared only in shutdownAll(), so a server
that failed (bad env, missing binary, or a since-fixed config) stayed
state:"error" for the whole process. For an agent running *inside*
dispatch the only recovery (server restart) kills its own session.
Now a broken server self-heals when its resolved config changes since
it was marked broken (discrete event → no retry storm), with a bounded
backoff for transient failures.
Issue 1: .dispatch/lsp.json silently shadowed opencode.json's lsp key
with no warning and no source attribution. Now: shadow warning via
host.logger when both declare lsp; configSource populated on status
(.dispatch/lsp.json / opencode.json / built-in); spawn-failure error
strings name the config source.
Contract: additive configSource?: string on LspServerInfo
(@dispatch/transport-contract 0.20.0→0.21.0). transport-http passes it
through to the wire (was a field-by-field map that dropped it — CR
resolved by the transport-http owner).
tsc -b EXIT 0, biome clean, 1443 vitest pass.
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 65 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 1 |
2 files changed, 66 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 153a63a..a84fa44 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -364,6 +364,7 @@ function createFakeLspService( readonly extensions: readonly string[]; readonly state: "connected" | "starting" | "error" | "not-started"; readonly error?: string; + readonly configSource?: string; }[] = [], ): LspService { return { @@ -381,6 +382,7 @@ function createCapturingLspService( readonly extensions: readonly string[]; readonly state: "connected" | "starting" | "error" | "not-started"; readonly error?: string; + readonly configSource?: string; }[] = [], ): LspService & { readonly statusCalls: readonly string[] } { const calls: string[] = []; @@ -2317,6 +2319,69 @@ describe("GET /conversations/:id/lsp", () => { expect(body.servers).toHaveLength(1); expect(body.servers[0]?.id).toBe("typescript"); }); + + it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Case 1: configSource is defined → reaches the wire verbatim. + const lspWithSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + configSource: ".dispatch/lsp.json", + }, + ]); + const appWithSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithSource, + logger: noopLogger, + }); + const resWithSource = await appWithSource.request("/conversations/conv1/lsp"); + expect(resWithSource.status).toBe(200); + const bodyWithSource = (await resWithSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly configSource?: string; + }[]; + }; + expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json"); + + // Case 2: configSource is undefined → the field is OMITTED from the + // response (proves exactOptionalPropertyTypes is respected — never + // stamping `undefined` onto the wire object). + const lspWithoutSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const appWithoutSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithoutSource, + logger: noopLogger, + }); + const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp"); + expect(resWithoutSource.status).toBe(200); + const bodyWithoutSource = (await resWithoutSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly Record<string, unknown>[]; + }; + expect(bodyWithoutSource.servers).toHaveLength(1); + expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource"); + }); }); describe("POST /chat reasoningEffort", () => { diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 7fdbb00..2a98ea0 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -703,6 +703,7 @@ export function createApp(opts: CreateServerOptions): Hono { extensions: s.extensions, state: s.state, ...(s.error !== undefined ? { error: s.error } : {}), + ...(s.configSource !== undefined ? { configSource: s.configSource } : {}), }; return info; }); |
