History

fix(opencode): handle child approvals, stops, and model catalogs (#8480)

Theo Browne committed 2 months agov0.0.36-nightly.20260828.1213

Commit cb49e5d1 parent 0009aac42 files · +7983 −675Browse files

Showing the changes to apps/server/src/textGeneration/OpenCodeTextGeneration.ts (1 of 42 files). Show all

Unified · Split

apps/server/src/textGeneration/OpenCodeTextGeneration.ts+30−194
@@ -1,9 +1,5 @@
11 import * as Effect from "effect/Effect";
2−import * as Exit from "effect/Exit";
3−import * as Fiber from "effect/Fiber";
42 import * as Schema from "effect/Schema";
5−import * as Scope from "effect/Scope";
6−import * as Semaphore from "effect/Semaphore";
73
84 import {
95 NonNegativeInt,
@@ -31,8 +27,7 @@ import { · 21 unmodified lines
3127 sanitizeThreadTitle,
3228 } from "./TextGenerationUtils.ts";
3329 import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
34−
35−const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
30+import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts";
3631
3732 const OpenCodeTextGenerationOperation = Schema.Literals([
3833 "generateCommitMessage",
@@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str · 136 unmodified lines
175170 .trim();
176171 }
177172
178−interface SharedOpenCodeTextGenerationServerState {
179− server: OpenCodeRuntime.OpenCodeServerProcess | null;
180− /**
181− * The scope that owns the shared server's lifetime. Closing this scope
182− * terminates the OpenCode child process and interrupts any fibers the
183− * runtime forked during startup. We don't hold a `close()` function on
184− * the server handle anymore — the scope is the only lifecycle handle.
185− */
186− serverScope: Scope.Closeable | null;
187− binaryPath: string | null;
188− activeRequests: number;
189− idleCloseFiber: Fiber.Fiber<void, never> | null;
190−}
191−
192173 export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
193174 openCodeSettings: OpenCodeSettings,
194− environment?: NodeJS.ProcessEnv,
195175 ) {
196176 const serverConfig = yield* ServerConfig.ServerConfig;
197177 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
198− const resolvedEnvironment = environment ?? process.env;
199− const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
200− Scope.close(scope, Exit.void),
201− );
202− const sharedServerMutex = yield* Semaphore.make(1);
203− const sharedServerState: SharedOpenCodeTextGenerationServerState = {
204− server: null,
205− serverScope: null,
206− binaryPath: null,
207− activeRequests: 0,
208− idleCloseFiber: null,
209− };
210−
211− const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
212− const scope = sharedServerState.serverScope;
213− sharedServerState.server = null;
214− sharedServerState.serverScope = null;
215− sharedServerState.binaryPath = null;
216− if (scope !== null) {
217− yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
218− }
219− });
220−
221− const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
222− const idleCloseFiber = sharedServerState.idleCloseFiber;
223− sharedServerState.idleCloseFiber = null;
224− if (idleCloseFiber !== null) {
225− yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
226− }
227− });
228−
229− const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
230− server: OpenCodeRuntime.OpenCodeServerProcess,
231− ) {
232− yield* cancelIdleCloseFiber();
233− const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
234− Effect.andThen(
235− sharedServerMutex.withPermit(
236− Effect.gen(function* () {
237− if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
238− return;
239− }
240− sharedServerState.idleCloseFiber = null;
241− yield* closeSharedServer();
242− }),
243− ),
244− ),
245− Effect.forkIn(idleFiberScope),
246− );
247− sharedServerState.idleCloseFiber = fiber;
248− });
249−
250− const acquireSharedServer = (input: {
251− readonly binaryPath: string;
252− readonly operation:
253− | "generateCommitMessage"
254− | "generatePrContent"
255− | "generateBranchName"
256− | "generateThreadTitle";
257− }) =>
258− sharedServerMutex.withPermit(
259− Effect.gen(function* () {
260− yield* cancelIdleCloseFiber();
261−
262− const existingServer = sharedServerState.server;
263− if (existingServer !== null) {
264− if (
265− sharedServerState.binaryPath !== input.binaryPath &&
266− sharedServerState.activeRequests === 0
267− ) {
268− yield* closeSharedServer();
269− } else {
270− if (sharedServerState.binaryPath !== input.binaryPath) {
271− yield* Effect.logWarning(
272− "OpenCode shared server binary path mismatch: requested " +
273− input.binaryPath +
274− " but active server uses " +
275− sharedServerState.binaryPath +
276− "; reusing existing server because there are active requests",
277− );
278− }
279− sharedServerState.activeRequests += 1;
280− return existingServer;
281− }
282− }
283−
284− // Create a fresh scope that owns this shared server. The runtime
285− // will attach its child-process and fiber finalizers to this scope;
286− // closing it kills the server and interrupts those fibers.
287− //
288− // The `Scope.make` / spawn / record-or-close transitions run inside
289− // `uninterruptibleMask` so an interrupt arriving between any two
290− // steps can't orphan the scope (and the child process attached to
291− // it) before we either close it on failure or hand ownership to
292− // `sharedServerState`. `restore` keeps the actual spawn
293− // interruptible; an interrupt during the spawn is captured by
294− // `Effect.exit` and drives us through the failure branch that
295− // closes the fresh scope.
296− return yield* Effect.uninterruptibleMask((restore) =>
297− Effect.gen(function* () {
298− const serverScope = yield* Scope.make();
299− const startedExit = yield* Effect.exit(
300− restore(
301− openCodeRuntime
302− .startOpenCodeServerProcess({
303− binaryPath: input.binaryPath,
304− environment: resolvedEnvironment,
305− })
306− .pipe(
307− Effect.provideService(Scope.Scope, serverScope),
308− Effect.mapError(
309− (cause) =>
310− new TextGenerationError({
311− operation: input.operation,
312− detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
313− cause,
314− }),
315− ),
316− ),
317− ),
318− );
319− if (startedExit._tag === "Failure") {
320− yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
321− return yield* Effect.failCause(startedExit.cause);
322− }
323−
324− const server = startedExit.value;
325− sharedServerState.server = server;
326− sharedServerState.serverScope = serverScope;
327− sharedServerState.binaryPath = input.binaryPath;
328− sharedServerState.activeRequests = 1;
329− return server;
330− }),
331− );
332− }),
333− );
334−
335− const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
336− sharedServerMutex.withPermit(
337− Effect.gen(function* () {
338− if (sharedServerState.server !== server) {
339− return;
340− }
341− sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
342− if (sharedServerState.activeRequests === 0) {
343− yield* scheduleIdleClose(server);
344− }
345− }),
346− );
347−
348− // Module-level finalizer: on layer shutdown, cancel the idle close fiber
349− // and close the shared server scope. Consumers therefore cannot leak
350− // the shared OpenCode server by forgetting to call anything.
351− yield* Effect.addFinalizer(() =>
352− sharedServerMutex.withPermit(
353− Effect.gen(function* () {
354− yield* cancelIdleCloseFiber();
355− sharedServerState.activeRequests = 0;
356− yield* closeSharedServer();
357− }),
358− ),
359− );
178+ const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
360179
361180 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
362181 readonly operation: OpenCodeTextGenerationOperation;
@@ -381,13 +200,16 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 18 unmodified lines
381200 });
382201
383202 const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")(
384− function* (server: Pick<OpenCodeRuntime.OpenCodeServerConnection, "url">) {
203+ function* (
204+ server: Pick<
205+ OpenCodeRuntime.OpenCodeServerConnection,
206+ "url" | "serverPassword" | "version"
207+ >,
208+ ) {
385209 const client = openCodeRuntime.createOpenCodeSdkClient({
386210 baseUrl: server.url,
387211 directory: input.cwd,
388− ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword
389− ? { serverPassword: openCodeSettings.serverPassword }
390− : {}),
212+ ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
391213 });
392214 const session = yield* Effect.tryPromise({
393215 try: () =>
@@ -496,17 +318,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 102 unmodified lines
496318 }),
497319 );
498320
499− const rawOutput =
321+ const serverOutput =
500322 openCodeSettings.serverUrl.length > 0
501− ? yield* runAgainstServer({ url: openCodeSettings.serverUrl })
502− : yield* Effect.acquireUseRelease(
503− acquireSharedServer({
323+ ? openCodeRuntime
324+ .connectToOpenCodeServer({
504325 binaryPath: openCodeSettings.binaryPath,
326+ directory: input.cwd,
327+ serverUrl: openCodeSettings.serverUrl,
328+ ...(openCodeSettings.serverPassword
329+ ? { serverPassword: openCodeSettings.serverPassword }
330+ : {}),
331+ })
332+ .pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
333+ : serverOwner.withServer(runAgainstServer);
334+ const rawOutput = yield* serverOutput.pipe(
335+ Effect.catchTags({
336+ OpenCodeRuntimeError: (cause) =>
337+ Effect.fail(
338+ new TextGenerationError({
505339 operation: input.operation,
340+ detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
341+ cause,
506342 }),
507− runAgainstServer,
508− releaseSharedServer,
509− );
343+ ),
344+ }),
345+ );
510346
511347 const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson));
512348 return yield* decodeOutput(extractJsonObject(rawOutput)).pipe(