diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
| commit | e7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch) | |
| tree | 447095fd60b43980358d1565506f3ae2430e5f29 /packages/session-orchestrator/src | |
| parent | 35937cee7f838e414eb8147c67205e01d85a4da0 (diff) | |
| download | dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip | |
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature:
- new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy
one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool
- `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a
turn's cwd from the store
- `transport-http`: cwd + lsp status endpoints; wire types in transport-contract
- host-bin: register lsp; config wiring
Cache-warming fix (the warm read 0% on the first reheat after a message):
- warm assembled tools under a different cwd than the real turn (a reheat sends no
cwd, and the warm service had no store fallback). The skills filter rewrites the
cwd-sensitive `load_skill` description, so the tools block — the first bytes of
the prompt-cache prefix — diverged and the cache missed entirely. Warm now
resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage.
- capture warm sends as `provider.request` spans flagged `warm:true` (thread a
child logger into providerOpts) so warm vs real bodies are diffable (obs §3.1).
- kernel logger: span-close now merges child-bound attrs like span-open, so a
`warm:true` query finds the closed span (with usage/status), not just the open.
Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger
open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 244 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 41 |
2 files changed, 278 insertions, 7 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index ba4912a..33deb15 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -3,8 +3,10 @@ import type { AgentEvent, ChatMessage, EventHookDescriptor, + Logger, ProviderContract, ProviderEvent, + ProviderStreamOptions, RunTurnInput, RunTurnResult, StoredChunk, @@ -24,12 +26,15 @@ import type { ToolAssembly } from "./tools-filter.js"; function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; readonly metricsData: Map<string, TurnMetrics[]>; + readonly cwdData: Map<string, string>; } { const data = new Map<string, ChatMessage[]>(); const metricsData = new Map<string, TurnMetrics[]>(); + const cwdData = new Map<string, string>(); return { data, metricsData, + cwdData, async append(conversationId, messages) { const existing = data.get(conversationId) ?? []; data.set(conversationId, [...existing, ...messages]); @@ -58,6 +63,12 @@ function createInMemoryStore(): ConversationStore & { async loadMetrics(conversationId) { return [...(metricsData.get(conversationId) ?? [])]; }, + async getCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdData.set(conversationId, cwd); + }, }; } @@ -518,6 +529,12 @@ describe("turn-sealed event", () => { async loadMetrics(conversationId) { return store.loadMetrics(conversationId); }, + async getCwd(conversationId) { + return store.getCwd(conversationId); + }, + async setCwd(conversationId, cwd) { + await store.setCwd(conversationId, cwd); + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -565,6 +582,10 @@ describe("turn-sealed event", () => { async loadMetrics() { return []; }, + async getCwd() { + return null; + }, + async setCwd() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -839,6 +860,10 @@ describe("turn metrics persistence", () => { async loadMetrics() { return []; }, + async getCwd() { + return null; + }, + async setCwd() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -1087,6 +1112,105 @@ describe("warm service", () => { } }); + it("warm forwards a `warm`-flagged logger so the send is captured as a span", async () => { + const store = createInMemoryStore(); + await store.append("conv-warm-log", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + let capturedOpts: ProviderStreamOptions | undefined; + const provider: ProviderContract = { + id: "p", + stream(_messages, _tools, opts) { + capturedOpts = opts; + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + // Minimal Logger stub recording the child() correlation it was asked for. + let childArg: (Partial<{ conversationId: string }> & { attrs?: unknown }) | undefined; + const warmChild = { __warmChild: true } as unknown as Logger; + const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + span() { + throw new Error("warm should not open spans directly"); + }, + child(ctx: { conversationId?: string; attrs?: unknown }) { + childArg = ctx; + return warmChild; + }, + } as unknown as Logger; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: () => {}, + logger, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-log"); + + // The warm send must carry the logger so the provider opens a provider.request span. + expect(capturedOpts?.logger).toBe(warmChild); + // …and it must be flagged warm + correlated to the conversation, so it can be + // diffed against the real turn's request (the 0%-cache debugging workflow). + expect(childArg).toMatchObject({ + conversationId: "conv-warm-log", + attrs: { warm: true }, + }); + }); + + it("warm falls back to the conversation's stored cwd for tool assembly", async () => { + // A cwd-sensitive tools filter (e.g. skill discovery) must see the SAME cwd + // the real turn used, or the tools block diverges and the prompt cache misses. + // A manual reheat sends no cwd, so the warm must fall back to the stored cwd. + const store = createInMemoryStore(); + await store.append("conv-warm-cwd", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store.setCwd("conv-warm-cwd", "/home/tradam/projects/roblox"); + + let assemblyCwd: string | undefined = "UNSET"; + const provider: ProviderContract = { + id: "p", + stream() { + return (async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + })(); + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: (assembly: ToolAssembly) => { + assemblyCwd = assembly.cwd; + return Promise.resolve(assembly); + }, + runTurn, + emit: () => {}, + }; + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + // No cwd in opts (the reheat case) → must use the stored cwd. + await warmService.warm("conv-warm-cwd"); + expect(assemblyCwd).toBe("/home/tradam/projects/roblox"); + }); + it("warm refuses while the conversation is generating", async () => { const store = createInMemoryStore(); let resolveRunTurn: (() => void) | undefined; @@ -1324,3 +1448,123 @@ describe("warm service", () => { expect(warmEmits).toHaveLength(0); }); }); + +describe("cwd persistence", () => { + it("uses the persisted cwd when the request omits cwd", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-persisted", "/persisted/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persisted", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/persisted/dir"); + }); + + it("persists the cwd when the request provides one (and a later cwd-less turn reuses it)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "first", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-persist-new")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-new", + text: "second", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("an explicit request cwd overrides the persisted cwd (and updates it)", async () => { + const store = createInMemoryStore(); + await store.setCwd("conv-override", "/old/dir"); + + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "override", + onEvent: () => {}, + cwd: "/new/dir", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/new/dir"); + expect(store.cwdData.get("conv-override")).toBe("/new/dir"); + + await orchestrator.handleMessage({ + conversationId: "conv-override", + text: "reused", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.cwd).toBe("/new/dir"); + }); + + it("no cwd is threaded when neither request nor store has one", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-cwd-either", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBeUndefined(); + }); +}); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 6df92c8..e86729c 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -117,15 +117,25 @@ export function createSessionOrchestrator( const orchestrator: SessionOrchestrator = { async handleMessage({ conversationId, text, onEvent, signal, modelName, cwd }) { + activeConversations.add(conversationId); + + const effectiveCwd = + cwd !== undefined + ? cwd + : ((await deps.conversationStore.getCwd(conversationId)) ?? undefined); + const payload: TurnLifecyclePayload = { conversationId, - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(modelName !== undefined ? { modelName } : {}), }; deps.emit?.(turnStarted, payload); - activeConversations.add(conversationId); try { + if (cwd !== undefined) { + await deps.conversationStore.setCwd(conversationId, cwd); + } + const history = await deps.conversationStore.load(conversationId); const userMsg = buildUserMessage(text); const turnId = generateTurnId(); @@ -154,7 +164,7 @@ export function createSessionOrchestrator( const assembled = await deps.applyToolsFilter({ tools: baseTools, conversationId, - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); @@ -178,7 +188,7 @@ export function createSessionOrchestrator( : {}), ...(turnLogger !== undefined ? { logger: turnLogger } : {}), ...(signal !== undefined ? { signal } : {}), - ...(cwd !== undefined ? { cwd } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(deps.now !== undefined ? { now: deps.now } : {}), }; @@ -231,7 +241,14 @@ export function createWarmService( } const baseTools = deps.resolveTools(); - const cwd = opts?.cwd; + // Resolve cwd the SAME way handleMessage does (caller value → stored cwd). + // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the + // `load_skill` description per-cwd). If the warm assembles tools under a + // different cwd than the real turn, the tools block — the FIRST bytes of + // the prompt-cache prefix — diverges and the cache misses entirely (0%). + // A manual reheat sends no cwd, so without this fallback it would warm the + // wrong prefix. See notes/observability-design.md §3.1. + const cwd = opts?.cwd ?? (await deps.conversationStore.getCwd(conversationId)) ?? undefined; const assembled = await deps.applyToolsFilter({ tools: baseTools, conversationId, @@ -244,8 +261,18 @@ export function createWarmService( }; const messages = [...history, probeMsg]; - const providerOpts: ProviderStreamOptions | undefined = - modelOverride !== undefined ? { model: modelOverride, maxTokens: 1 } : { maxTokens: 1 }; + // Capture the warm send as a `provider.request` span, flagged `warm: true` + // so it can be diffed against the corresponding real turn's request (the + // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md + // §3.1). Without this the warm body is invisible and the cache bust is + // undebuggable. The child-bound `warm` attribute flows into the span the + // provider opens (kernel logger merges child attrs into span attributes). + const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); + const providerOpts: ProviderStreamOptions = { + maxTokens: 1, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(warmLogger !== undefined ? { logger: warmLogger } : {}), + }; let inputTokens = 0; let outputTokens = 0; |
