summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 13:01:18 +0900
committerAdam Malczewski <[email protected]>2026-06-24 13:01:18 +0900
commit5630bf177c1f45d8e35ddbe35bf7d5136dc4f244 (patch)
tree7603569ef855fe3aa531f7c8bd125dff477eaf79 /packages/transport-http/src
parent1eb25dcace8c3cb0b3a3871a74d0dd3eaf169eb7 (diff)
downloaddispatch-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.ts65
-rw-r--r--packages/transport-http/src/app.ts1
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;
});