diff options
| author | Adam Malczewski <[email protected]> | 2026-06-23 21:07:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-23 21:07:53 +0900 |
| commit | 4158e699e3c8ff556684fe2fc7a39ffab040623e (patch) | |
| tree | 1ca7b3069f6b1cfcb119c4b1fae614baf20f6e69 | |
| parent | 961f564f6de6c6c63970b0aeaa48f730cc028d92 (diff) | |
| download | dispatch-4158e699e3c8ff556684fe2fc7a39ffab040623e.tar.gz dispatch-4158e699e3c8ff556684fe2fc7a39ffab040623e.zip | |
fix(lsp): gate LSP endpoint on persisted cwd; accept workspaceId on PUT cwd
GET /conversations/:id/lsp was calling getEffectiveCwd directly, which falls
through to serverDefaultCwd (process.cwd()) when no conversation cwd is set.
Now gates on getCwd first: returns {cwd:null, servers:[]} when no cwd
persisted; only resolves via getEffectiveCwd + calls lspService.status when
a persisted cwd exists.
PUT /conversations/:id/cwd now accepts optional workspaceId — validates with
isValidWorkspaceSlug, then ensureWorkspace → setWorkspaceId → setCwd (assigns
the workspace before persisting cwd, so getEffectiveCwd resolves relative
cwds against the workspace defaultCwd, not the server default).
transport-contract 0.16.0→0.17.0 (additive SetCwdRequest.workspaceId;
LspStatusResponse.cwd comment updated).
1332 vitest pass.
| -rw-r--r-- | frontend-lsp-cwd-workspace-handoff.md | 75 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 27 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 278 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 107 | ||||
| -rw-r--r-- | tasks.md | 132 |
6 files changed, 594 insertions, 27 deletions
diff --git a/frontend-lsp-cwd-workspace-handoff.md b/frontend-lsp-cwd-workspace-handoff.md new file mode 100644 index 0000000..7e34211 --- /dev/null +++ b/frontend-lsp-cwd-workspace-handoff.md @@ -0,0 +1,75 @@ +# FE Courier Handoff: LSP cwd resolution fix + PUT cwd workspaceId + +> Backend→FE courier. The user couriers this to `../dispatch-web` (FE agent `ffe3`). +> No `@dispatch/wire` or `@dispatch/transport-contract` version bump is breaking — +> the `SetCwdRequest.workspaceId` is additive (optional); the `LspStatusResponse.cwd` +> semantics changed (was always non-null effective cwd; now null when no cwd set). + +## What changed (backend) + +### 1. `GET /conversations/:id/lsp` — behavior change + +**Before:** The endpoint called `getEffectiveCwd(conversationId)` directly. When no +cwd was persisted, this fell through to the server default (`process.cwd()`) — so the +LSP connected on the wrong directory (the server's cwd, not the conversation's +workspace). + +**After:** The endpoint now gates on the **persisted** cwd (`getCwd`) first: +- When no cwd is persisted → response is `{ cwd: null, servers: [] }` (HTTP 200, no + LSP connection). The LSP does NOT connect when no working directory is set. +- When a cwd IS persisted → the endpoint resolves the **effective** cwd (relative cwd + resolved against the workspace `defaultCwd`; absolute → as-is) and returns + `{ cwd: "<effectiveCwd>", servers: [...] }`. + +**FE impact:** +- `LspStatusResponse.cwd` can now be `null` (previously it was always a string, even + when no cwd was set — it returned `process.cwd()`). The FE should handle `null` by + showing "no LSP connected" or "set a working directory." +- When `cwd` is non-null, it is the RESOLVED (effective) cwd — an absolute path. The FE + can display this as the directory the LSP is connected on. + +### 2. `PUT /conversations/:id/cwd` — new optional `workspaceId` field + +**Before:** The `PUT /conversations/:id/cwd` body was `{ cwd: string }` — only set +the persisted cwd, no workspace assignment. + +**After:** The body now accepts an optional `workspaceId`: +```json +{ "cwd": "/home/user/project", "workspaceId": "my-team" } +``` + +When `workspaceId` is provided: +1. The conversation is assigned to that workspace (via `ensureWorkspace` + + `setWorkspaceId`) BEFORE the cwd is persisted. +2. This ensures a subsequent `GET /conversations/:id/lsp` resolves a relative cwd + against the workspace's `defaultCwd` (not the server default). +3. Invalid `workspaceId` (not a valid slug: lowercase `[a-z0-9-]`, 1–40 chars) → + HTTP 400 `{ error: "Invalid workspaceId" }`. + +When `workspaceId` is absent → behavior is unchanged (just `setCwd`). + +**FE action:** When the user sets the working directory on a new chat tab, send the +`workspaceId` alongside the `cwd` in the `PUT /conversations/:id/cwd` request. This +ensures the LSP resolves correctly even before the first turn. + +### Example flow (new chat tab) + +1. User opens a new chat tab (selects workspace "my-team" with + `defaultCwd: "/home/tradam/projects/dispatch"`) +2. User sets working dir to `"arch-rewrite"` (relative) +3. FE sends: `PUT /conversations/abc/cwd { "cwd": "arch-rewrite", "workspaceId": "my-team" }` +4. Backend: assigns conversation to workspace "my-team", then persists cwd "arch-rewrite" +5. FE calls: `GET /conversations/abc/lsp` +6. Backend: `getCwd("abc")` → `"arch-rewrite"` (non-null) → `getEffectiveCwd("abc")` → + resolves "arch-rewrite" against workspace "my-team"'s `defaultCwd` + (`"/home/tradam/projects/dispatch"`) → `"/home/tradam/projects/dispatch/arch-rewrite"` +7. Response: `{ cwd: "/home/tradam/projects/dispatch/arch-rewrite", servers: [...] }` + +Without the `workspaceId` on the PUT (step 3), the conversation would be in the +`"default"` workspace (defaultCwd: null), and the relative cwd "arch-rewrite" would +resolve against `process.cwd()` — the wrong directory. + +## Contract version + +`@dispatch/transport-contract` bumped to `0.17.0` (additive: `SetCwdRequest.workspaceId` +is optional; `LspStatusResponse.cwd` comment updated — no field type change). diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 1923e9f..95e1aef 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.16.0", + "version": "0.17.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 583f986..b195171 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -181,6 +181,14 @@ export interface ConversationMetricsResponse { readonly turns: readonly TurnMetrics[]; } +export interface ConversationStatusResponse { + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; +} + /** The aggregation window for `GET /metrics/throughput`. */ export type ThroughputPeriod = "day" | "week" | "month"; @@ -232,9 +240,19 @@ export interface CwdResponse { readonly cwd: string | null; } -/** Body of `PUT /conversations/:id/cwd`. */ +/** + * Body of `PUT /conversations/:id/cwd`. + * + * When `workspaceId` is provided, the conversation is assigned to that + * workspace BEFORE the cwd is persisted — so a subsequent + * `GET /conversations/:id/lsp` resolves a relative cwd against the + * workspace's `defaultCwd` (not the server default). Omit for unchanged + * workspace assignment (the conversation keeps its current workspace, or + * `"default"` if none). + */ export interface SetCwdRequest { readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -345,7 +363,12 @@ export interface LspServerInfo { /** Response of `GET /conversations/:id/lsp`. */ export interface LspStatusResponse { readonly conversationId: string; - /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */ + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ readonly cwd: string | null; /** The language servers configured for `cwd` and their live state. */ readonly servers: readonly LspServerInfo[]; diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 1390199..0b840db 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -125,6 +125,9 @@ function createFakeConversationStore( async setCwd(conversationId, cwd) { cwdStore.set(conversationId, cwd); }, + async clearCwd(conversationId) { + cwdStore.delete(conversationId); + }, async getReasoningEffort(conversationId) { return reasoningEffortStore.get(conversationId) ?? null; }, @@ -177,6 +180,35 @@ function createFakeConversationStore( }; } +/** + * Wraps a ConversationStore to record the ORDER of mutating calls + * (ensureWorkspace, setWorkspaceId, setCwd) as a labeled list — so tests can + * assert that workspace assignment happens BEFORE setCwd. + */ +function createCallTrackingStore( + base: ConversationStore, +): ConversationStore & { readonly calls: readonly string[] } { + const calls: string[] = []; + return { + ...base, + get calls() { + return calls; + }, + async ensureWorkspace(id, opts) { + calls.push(`ensureWorkspace:${id}`); + return base.ensureWorkspace(id, opts); + }, + async setWorkspaceId(conversationId, workspaceId) { + calls.push(`setWorkspaceId:${workspaceId}`); + await base.setWorkspaceId(conversationId, workspaceId); + }, + async setCwd(conversationId, cwd) { + calls.push(`setCwd:${cwd}`); + await base.setCwd(conversationId, cwd); + }, + }; +} + function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { return { startTurn() { @@ -327,6 +359,28 @@ function createFakeLspService( }; } +function createCapturingLspService( + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + }[] = [], +): LspService & { readonly statusCalls: readonly string[] } { + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -895,6 +949,7 @@ describe("GET /conversations/:id", () => { return null; }, async setCwd() {}, + async clearCwd() {}, async getReasoningEffort() { return null; }, @@ -1013,6 +1068,7 @@ describe("GET /conversations/:id", () => { return null; }, async setCwd() {}, + async clearCwd() {}, async getReasoningEffort() { return null; }, @@ -1200,6 +1256,7 @@ describe("GET /conversations/:id/metrics", () => { return null; }, async setCwd() {}, + async clearCwd() {}, async getReasoningEffort() { return null; }, @@ -1888,6 +1945,78 @@ describe("PUT then GET /conversations/:id/cwd", () => { }); }); +describe("DELETE /conversations/:id/cwd", () => { + it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBeNull(); + }); + + it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + }); + + it("does NOT affect other conversations' cwds (isolation)", async () => { + const cwdStore = new Map<string, string>([ + ["conv1", "/home/user/project"], + ["conv2", "/other/path"], + ]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1Res = await app.request("/conversations/conv1/cwd"); + expect(get1Res.status).toBe(200); + const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null }; + expect(get1Body.cwd).toBeNull(); + + const get2Res = await app.request("/conversations/conv2/cwd"); + expect(get2Res.status).toBe(200); + const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null }; + expect(get2Body.cwd).toBe("/other/path"); + }); +}); + describe("PUT /conversations/:id/cwd", () => { it("with missing cwd returns 400", async () => { const app = createApp({ @@ -1920,6 +2049,80 @@ describe("PUT /conversations/:id/cwd", () => { }); expect(res.status).toBe(400); }); + + it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string }; + expect(body.cwd).toBe("/home/user/project"); + // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd. + expect(store.calls).toEqual([ + "ensureWorkspace:my-team", + "setWorkspaceId:my-team", + "setCwd:/home/user/project", + ]); + }); + + it("PUT cwd without workspaceId: only setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(res.status).toBe(200); + // ensureWorkspace and setWorkspaceId NOT called. + expect(store.calls).toEqual(["setCwd:/home/user/project"]); + }); + + it("PUT cwd with invalid workspaceId: returns 400", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + // Uppercase is not a valid workspace slug. + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Invalid workspaceId"); + // No mutating calls should have been made. + expect(store.calls).toEqual([]); + + // Empty string is also invalid. + const res2 = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }), + }); + expect(res2.status).toBe(400); + }); }); describe("GET /conversations/:id/lsp", () => { @@ -1994,6 +2197,79 @@ describe("GET /conversations/:id/lsp", () => { expect(body.servers[1]?.state).toBe("error"); expect(body.servers[1]?.error).toBe("spawn failed"); }); + + it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => { + const cwdStore = new Map<string, string>(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/irrelevant", + extensions: [".ts"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(lsp.statusCalls).toEqual([]); // status NOT called + }); + + it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + // Persisted (relative) cwd differs from the resolved effective cwd. + const cwdStore = new Map<string, string>([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Override getEffectiveCwd to return the resolved (absolute) value. + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/workspace/subdir", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(lsp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("typescript"); + }); }); describe("POST /chat reasoningEffort", () => { @@ -2964,7 +3240,7 @@ it("GET /conversations/:id/lsp uses effective cwd", async () => { const res = await app.request("/conversations/conv1/lsp"); expect(res.status).toBe(200); expect(effectiveCwdCalled).toBe(true); - expect(getCwdCalled).toBe(false); + expect(getCwdCalled).toBe(true); // gated on persisted cwd first expect(lspCwd).toBe("/effective"); const body = (await res.json()) as { conversationId: string; diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index b4ab513..eba5c1a 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -6,6 +6,7 @@ import type { ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, + ConversationStatusResponse, CwdResponse, DeleteWorkspaceResponse, LastMessageResponse, @@ -234,6 +235,17 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + app.get("/conversations/:id/status", async (c) => { + const conversationId = c.req.param("id"); + const isActive = opts.orchestrator.isActive(conversationId); + const status = await opts.conversationStore.getConversationStatus(conversationId); + if (status === null) { + return c.json({ error: "Conversation not found" }, 404); + } + const body: ConversationStatusResponse = { conversationId, isActive, status }; + return c.json(body, 200); + }); + app.get("/models", async (c) => { try { const models = await opts.credentialStore.listCatalog(); @@ -281,6 +293,7 @@ export function createApp(opts: CreateServerOptions): Hono { const events: AgentEvent[] = []; let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined; + let streamClosed = false; const stream = new ReadableStream<Uint8Array>({ start(controller) { @@ -288,12 +301,38 @@ export function createApp(opts: CreateServerOptions): Hono { }, }); + function safeEnqueue(data: Uint8Array): void { + if (streamClosed) return; + try { + controllerRef?.enqueue(data); + } catch (err) { + streamClosed = true; + log.warn("chat: stream enqueue failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + function safeClose(): void { + if (streamClosed) return; + streamClosed = true; + try { + controllerRef?.close(); + } catch (err) { + log.warn("chat: stream close failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = { conversationId, text: message, onEvent: (event) => { events.push(event); - controllerRef?.enqueue(new TextEncoder().encode(serializeEventLine(event))); + safeEnqueue(new TextEncoder().encode(serializeEventLine(event))); }, ...(model !== undefined ? { modelName: model } : {}), ...(cwd !== undefined ? { cwd } : {}), @@ -304,7 +343,7 @@ export function createApp(opts: CreateServerOptions): Hono { opts.orchestrator .handleMessage(orchestratorInput) .then(async () => { - controllerRef?.close(); + safeClose(); await recordThroughput(events, model); }) .catch((err) => { @@ -315,8 +354,8 @@ export function createApp(opts: CreateServerOptions): Hono { turnId: "", message: err instanceof Error ? err.message : String(err), }; - controllerRef?.enqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); - controllerRef?.close(); + safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); + safeClose(); }); return new Response(stream, { @@ -485,7 +524,22 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); } + // When a workspaceId is provided, assign the conversation to that + // workspace BEFORE persisting the cwd — so a subsequent + // GET /conversations/:id/lsp resolves a relative cwd against the + // workspace's defaultCwd (not the server default). Omit for unchanged + // workspace assignment (backward compatible). + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } await opts.conversationStore.setCwd(conversationId, obj.cwd); log.info("conversations: cwd set", { conversationId }); const response: CwdResponse = { conversationId, cwd: obj.cwd }; @@ -496,19 +550,17 @@ export function createApp(opts: CreateServerOptions): Hono { } }); - app.delete("/conversations/:id/cwd", (c) => { + app.delete("/conversations/:id/cwd", async (c) => { const conversationId = c.req.param("id"); - // CR: The ConversationStore interface currently has no `clearCwd` method. - // `setCwd(id, "")` would persist an empty string (not the same as "no - // cwd key"), and `getEffectiveCwd` treats any non-null explicit cwd as - // set — so an empty string would shadow the workspace defaultCwd instead - // of clearing. A `clearCwd` method (wrapping `storage.delete(cwdKey(id))`) - // is needed on the store interface to truly clear the persisted key. - // For now the route returns the contract shape (cwd: null); the actual - // clearing is a no-op until the CR is implemented. - log.info("conversations: cwd cleared", { conversationId }); - const response: CwdResponse = { conversationId, cwd: null }; - return c.json(response, 200); + try { + await opts.conversationStore.clearCwd(conversationId); + log.info("conversations: cwd cleared", { conversationId }); + const response: CwdResponse = { conversationId, cwd: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd clear failure", { err }); + return c.json({ error: "Failed to clear conversation cwd" }, 500); + } }); app.get("/conversations/:id/reasoning-effort", async (c) => { @@ -557,19 +609,32 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/conversations/:id/lsp", async (c) => { const conversationId = c.req.param("id"); try { - const cwd = await opts.conversationStore.getEffectiveCwd(conversationId); - if (cwd === null) { + // Gate on the PERSISTED cwd first: when no cwd has been set for the + // conversation, the LSP does NOT connect (return null + empty servers) + // rather than falling through to the server default (process.cwd()). + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { log.info("conversations: lsp status read (no cwd)", { conversationId }); const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; return c.json(body, 200); } + // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd + // resolved against the workspace defaultCwd; absolute → as-is). + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + // Edge case: persisted cwd exists but resolution returned null. + log.info("conversations: lsp status read (no effective cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + if (opts.lspService === undefined) { log.warn("conversations: lsp service not available", { conversationId }); return c.json({ error: "LSP service not available" }, 503); } - const statuses = await opts.lspService.status(cwd); + const statuses = await opts.lspService.status(effectiveCwd); const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { const info: LspServerInfo = { id: s.id, @@ -583,10 +648,10 @@ export function createApp(opts: CreateServerOptions): Hono { }); log.info("conversations: lsp status read", { conversationId, - cwd, + cwd: effectiveCwd, serverCount: servers.length, }); - const body: LspStatusResponse = { conversationId, cwd, servers }; + const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers }; return c.json(body, 200); } catch (err) { log.error("conversations: lsp status failure", { err }); @@ -5,7 +5,96 @@ > Keep this lean and current; do not let it re-accrete a step-by-step changelog. ## Status (current) -`tsc -b` EXIT 0 · biome clean · **1240 vitest + 199 transport bun green**. +`tsc -b` EXIT 0 · biome clean · **1332 vitest** green. + +## LSP cwd resolution — server-default fallthrough + workspace assignment (DONE) +Bug: `GET /conversations/:id/lsp` called `getEffectiveCwd` directly, which falls through +to `serverDefaultCwd` (`process.cwd()`) when no conversation cwd is set — the LSP +connected on the wrong dir. Additionally, a new conversation's workspace isn't assigned +until the first `chat.send`, so `getEffectiveCwd` resolved against `"default"` (not the +intended workspace) when the FE set the cwd before the first turn. +- **Wave 0 (orchestrator, contracts):** `@dispatch/transport-contract` `0.16.0→0.17.0` — + additive `SetCwdRequest.workspaceId?: string` + updated `LspStatusResponse.cwd` comment + ("resolved working directory the LSP connects on, or null when no cwd is set"). +- **Wave 1 — transport-http:** `GET /conversations/:id/lsp` now gates on `getCwd` + (persisted) first — returns `{ cwd: null, servers: [] }` when no cwd set (LSP does NOT + connect); only calls `getEffectiveCwd` + `lspService.status()` when a persisted cwd + exists. `PUT /conversations/:id/cwd` now accepts optional `workspaceId` — validates + with `isValidWorkspaceSlug`, then `ensureWorkspace` → `setWorkspaceId` → `setCwd` + (assigns workspace before persisting cwd). 5 new tests + 1 assertion updated. + Report: `reports/transport-http.md`. +- [x] Verified: `tsc -b` EXIT 0, biome clean, **1332 vitest** pass; agent in-lane. +- [x] **FE courier** sent to FE agent `ffe3`: `frontend-lsp-cwd-workspace-handoff.md` + — send `workspaceId` on `PUT /conversations/:id/cwd`; `GET /conversations/:id/lsp` + now returns `cwd: null` + empty `servers` when no working dir is set. + +## Workspace cwd fallthrough + relative resolution (DONE) +FE courier in: bug report + behavior change (`workspace defaultCwd` not used at turn start when +a conversation has no explicit cwd; plus per-conversation cwd should be **relative to the workspace +`defaultCwd`** unless absolute). Resolution is backend-owned (the FE omits `cwd` on `chat.send`). +- **Scope:** single unit — `conversation-store` owns `getEffectiveCwd` (already consumed unchanged + by `session-orchestrator` turn/warm + `transport-http` `GET /conversations/:id/lsp`), so no + cross-package surface change and no fan-out. `GET /conversations/:id/cwd` uses `getCwd` (raw + explicit cwd) — unchanged. +- [x] **conversation-store** — added injectable `serverDefaultCwd` (default `process.cwd()`) to + `createConversationStore`; rewrote `getEffectiveCwd` with the new algorithm: explicit conversation + cwd null → `workspaceCwd ?? serverDefaultCwd` (bug fix: was returning null, skipping the workspace + default); absolute (starts `/`) → overrides; relative → `path.resolve(workspaceCwd ?? + serverDefaultCwd, conversationCwd)`. Public signature `(conversationId) => Promise<string | null>` + unchanged. 8 regression tests. Report: `reports/conversation-store-workspace-cwd.md`. +- [x] Verified: `tsc -b` EXIT 0, biome clean, **1289 vitest** pass; agent in-lane; zero internal mocks. + +## Per-turn cwd override not resolved relative to workspace (CURRENT — live-found) +Live investigation (dev stack, tab 4ef4 in workspace `test` with `defaultCwd=/home/tradam/projects/ +dispatch`): `getEffectiveCwd` resolves a persisted relative cwd correctly (LSP endpoint + a chat +**omitting** `cwd` both return `/home/tradam/projects/dispatch/arch-rewrite`). BUT a per-turn `cwd` +sent on `chat.send` is used **as-is** by `session-orchestrator` (`cwd !== undefined ? +Promise.resolve(cwd)`, orchestrator.ts:360), bypassing `getEffectiveCwd`. So raw `arch-rewrite` +reaches `run_shell` → `resolve("arch-rewrite")` = `<process.cwd>/arch-rewrite` (nonexistent) → `pwd` +broken; `./` → `resolve("./")` = `process.cwd()` (valid) → "works". The FE sends the CwdField value +as a per-turn `cwd` (transport-ws threads it: router.ts:173 → extension.ts:277). +- **Fix (2 waves):** add an optional `overrideCwd?: string` to `ConversationStore.getEffectiveCwd` + (resolve the override if provided, else the persisted `getCwd` — same relative algorithm), then + `session-orchestrator` passes the per-turn `cwd` (turn start + warm `opts.cwd`) as the override. +- [x] **Wave 1 — conversation-store:** added `overrideCwd?` param + impl + tests. +- [x] **Wave 2 — session-orchestrator:** pass per-turn cwd as override (turn start + warm) + tests. +- [x] Verified: `tsc -b` EXIT 0, biome clean, **1298 vitest** pass; both agents in-lane; zero + internal mocks. +- [x] **LIVE-VERIFIED** (dev stack, workspace `test` defaultCwd `/home/tradam/projects/dispatch`): + a per-turn `cwd:"arch-rewrite"` on an existing conversation (assigned to `test`) → `pwd` + returns `/home/tradam/projects/dispatch/arch-rewrite` (resolved, not broken). Both the + omit-cwd path (Wave 0) and the per-turn-cwd path (Wave 2) confirmed working. +- **Known edge case (pre-existing, not a regression):** a brand-NEW conversation's FIRST turn runs + `getEffectiveCwd` *before* the workspace is assigned (orchestrator.ts assigns it later in the + IIFE), so a relative per-turn cwd resolves against the "default" workspace (server default) + instead of the intended one. Uncommon (CwdField typically set after the first message). Deferred. +- **Note (separate pre-existing bug, not touched):** `DELETE /conversations/:id/cwd` returns + `cwd:null` but does NOT clear the persisted cwd (transport-http app.ts:538 — the route is a stub). + +## Cwd edge cases — timing + DELETE stub (DONE) +Two pre-existing bugs surfaced during live-verify of the relative-cwd fix: +- **Edge 1 (timing):** a NEW conversation's first turn ran `getEffectiveCwd` BEFORE the workspace + was assigned, so a relative per-turn cwd resolved against `"default"` (server default) not the + intended workspace. **Fix:** session-orchestrator now assigns the workspace (for new + conversations, detected via `getConversationMeta === null`) BEFORE resolving the effective cwd; + removed the duplicate assignment site. 3 tests. +- **Edge 2 (DELETE stub):** `DELETE /conversations/:id/cwd` returned `{cwd:null}` but did NOT + clear the persisted cwd (no `clearCwd` on the store). **Fix:** conversation-store added + `clearCwd(id)` (`storage.delete(cwdKey)`, idempotent) + tests; transport-http DELETE handler now + `await clearCwd` for real. +- [x] **Wave A (parallel):** conversation-store (clearCwd) + session-orchestrator (timing) — disjoint. +- [x] **Wave B:** transport-http (DELETE handler uses clearCwd). +- [x] Verified: `tsc -b` EXIT 0, biome clean, **1311 vitest** pass; all in-lane; zero internal mocks. +- [x] **LIVE-VERIFIED** (dev stack): Edge 2 — PUT→GET(`/tmp/test`)→DELETE→GET(`null`) actually + cleared. Edge 1 — NEW conversation, workspace `test`, per-turn `cwd:"arch-rewrite"` → `pwd` + returns `/home/tradam/projects/dispatch/arch-rewrite` (resolved against workspace default, not + broken). +- [x] **FE courier handoff** written + sent: `frontend-cwd-resolution-handoff.md` couriered to FE + orchestrator conversation `b18a` via `dispatch send b18a --queue` (turn started). Behavior-only + — no `@dispatch/wire`/`transport-contract`/`ui-contract` version bumps; no FE contract change + needed. Notes: `DELETE /conversations/:id/cwd` now actually clears; per-turn `cwd` on `chat.send` + resolved relative to workspace `defaultCwd`; FE MAY omit `cwd` on `chat.send` (backend resolves + persisted). Built and verified live (full-fidelity: every feature is a manifest-loaded extension through the host): @@ -626,6 +715,45 @@ Outbound courier: `frontend-workspaces-handoff.md` (final shapes + Q1–Q8). `compactThreshold` (default 85%) is exceeded. `GET/PUT /conversations/:id/compact-percent` for the setting. `conversation.compacted` WS broadcast. CLI `dispatch compact <id>`. FE handoff: - `frontend-compaction-handoff.md`. + `frontend-compaction-handoff.md`. + 11. **FE: consume `GET /conversations/:id/status` for crash-recovery re-sync.** + Backend endpoint shipped (branch `fix/stuck-generating`): returns + `{ conversationId, isActive, status }` where `isActive` is the orchestrator's + in-memory truth and `status` is the persisted lifecycle status. On reconnect + (WS re-establish or page reload), the FE should call this for any tab it + believes is "generating"; if `isActive: false`, override the local spinner + to idle regardless of the persisted `status` (defense-in-depth against + status drift the boot-sweep didn't catch). No FE handoff doc needed — the + endpoint is self-documenting (`GET /conversations/:id/status`). (Done and dropped from the list: CLI; dedup / storage growth; message queue + steering injection.) + +## Stop generation must abort a hanging tool + not brick the conversation (DONE) +FE courier in: "Stop generation doesn't abort a hanging tool call." When the user clicks Stop during +a tool that hangs (e.g. `run_shell` with a blocking/grandchild-holding process), the turn never +sealed → the FE spinner spun forever AND the conversation was bricked (next `chat.send` rejected as +`"already-active"` because `activeTurns` was never cleared). +- **Root cause:** the kernel's `executeToolCall` awaited `tool.execute(...)` with **no race against + the abort signal** — a tool that ignored `ctx.signal` (or blocked on something it couldn't + interrupt) blocked `drain` → `runTurn` never returned → session-orchestrator's `finally` (which + clears `activeTurns`) never ran. (The `/stop` endpoint, `stopTurn`, and the `finally` cleanup were + already correct — they just needed `runTurn` to return.) Secondary: `realSpawn` resolved on + `child.on("close")` (waits for stdio) and killed only the immediate child, so a grandchild holding + the pipes could stall the spawn promise + leak. +- [x] **kernel** — `executeToolCall` now **races** `tool.execute` against `signal` via `Promise.race`; + on abort it **resolves** (not rejects) `{ content: "Aborted", isError: true }` so the step completes + normally → kernel's existing `signal.aborted → finishReason "aborted"` path runs → turn seals + cleanly (`done` + `turn-sealed`) → `finally` clears `activeTurns` → **conversation freed, next + message accepted**. Late rejections from the orphaned tool promise are swallowed. 11 tests incl. + the durability test (hanging tool `new Promise(() => {})` + abort → `runTurn` returns + `finishReason "aborted"`, doesn't hang). Report: `reports/kernel-abort-race.md`. +- [x] **tool-shell** — `realSpawn` spawns `detached: true` (own process group); on abort **and** + timeout kills the **group** (`process.kill(-pgid, "SIGKILL")`) AND resolves immediately (no + `close`-dependency) so a grandchild holding the pipes can't stall the spawn or leak. 4 tests + (grandchild abort, grandchild timeout, normal-completion stdout capture, simple abort). Report: + `reports/tool-shell-process-group-kill.md`. +- [x] Verified: `tsc -b` EXIT 0, biome clean, **1326 vitest** pass; both in-lane; kernel zero + internal mocks. +- [ ] **Live-verify** (needs a fresh `bin/up` — the dev stack is currently wedged, the very symptom + of this bug): start a hanging tool (`run_shell` sleep/grandchild), Stop, then send a NEW message → + it must be ACCEPTED (conversation not bricked) and the spinner clears. |
