Unified · Split
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "@executor-js/api/server"; |
| 13 | 13 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 14 | 14 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 15 | −import { env } from "cloudflare:workers"; | |
| 15 | +import { env, waitUntil } from "cloudflare:workers"; | |
| 16 | 16 | |
| 17 | 17 | import type { CloudflareConfig } from "./config"; |
| 18 | 18 | import { makeCloudflarePlugins } from "./plugins"; |
| @@ -56,6 +56,12 @@ export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer< · 37 unmodified lines | ||
| 56 | 56 | allowLocalNetwork: config.allowLocalNetwork, |
| 57 | 57 | webBaseUrl: config.webBaseUrl, |
| 58 | 58 | oauthCallbackPath: "/api/oauth/callback", |
| 59 | + // A Worker, so a tools read's speculative catalog refresh needs `waitUntil` | |
| 60 | + // to survive the response at all. Unlike cloud's Hyperdrive plane there is | |
| 61 | + // nothing to drain it before: the storage here is a D1 binding read | |
| 62 | + // straight off `env`, with no pool and no close finalizer, so a detached | |
| 63 | + // batch still has everything it needs after the response is written. | |
| 64 | + deferToolSync: (task) => Effect.sync(() => waitUntil(Effect.runPromise(task))), | |
| 59 | 65 | }); |
| 60 | 66 | |
| 61 | 67 | /** |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Layer } from "effect"; | |
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | CodeExecutorProvider, |
| @@ -60,6 +60,13 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig · 55 unmodified lines | ||
| 60 | 60 | event.kind === "added" ? "integration_added" : "integration_removed", |
| 61 | 61 | { plugin_key: event.pluginKey }, |
| 62 | 62 | ), |
| 63 | + // Self-host is one long-lived process over one long-lived libSQL handle, so | |
| 64 | + // a speculative catalog refresh just goes to a detached fiber: the handle | |
| 65 | + // it needs outlives every request, and there is no isolate to keep alive. | |
| 66 | + // Detached, not `forkScoped` — the request scope this is called from closes | |
| 67 | + // as soon as the response is written, which is precisely what the work has | |
| 68 | + // to outlive. The task is total by contract, so nothing escapes the fork. | |
| 69 | + deferToolSync: (task) => Effect.asVoid(Effect.forkDetach(task)), | |
| 63 | 70 | }; |
| 64 | 71 | }); |
| 65 | 72 | |
| @@ -201,6 +201,11 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 200 unmodified lines | ||
| 201 | 201 | event.kind === "added" ? "integration_added" : "integration_removed", |
| 202 | 202 | { plugin_key: event.pluginKey }, |
| 203 | 203 | ), |
| 204 | + // The daemon owns its sqlite handle for its whole lifetime, so a tools | |
| 205 | + // read's speculative catalog refresh goes to a detached fiber and the | |
| 206 | + // read answers without waiting on an MCP server's handshake. The task | |
| 207 | + // is total by contract, so nothing escapes the fork. | |
| 208 | + deferToolSync: (task) => Effect.asVoid(Effect.forkDetach(task)), | |
| 204 | 209 | onElicitation: "accept-all", |
| 205 | 210 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 206 | 211 | // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` |