diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446b3..ba0541c0be94 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -9,7 +9,7 @@ import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" -import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { awaitSessionsIdle, disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" Heap.start() @@ -26,6 +26,7 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +let reloading: Promise | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { @@ -61,13 +62,23 @@ export const rpc = { await upgrade().catch(() => {}) }, async reload() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - yield* cfg.invalidate() - yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) - }), - ) + // SIGUSR2 arrives from desktop environments on theme changes, so a reload + // can land mid-run. Swapping config in disposes every instance, which + // cancels the session that is currently working — wait for it to finish + // instead. Signals that arrive while waiting join the pending reload. + if (!reloading) { + reloading = AppRuntime.runPromise( + Effect.gen(function* () { + yield* awaitSessionsIdle() + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ).finally(() => { + reloading = undefined + }) + } + await reloading }, async shutdown() { await InstanceRuntime.disposeAllInstances() diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549ddaff7..c9188d9b0e0b 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -19,6 +19,7 @@ export interface LoadInput { export interface Interface { readonly load: (input: LoadInput) => Effect.Effect + readonly list: () => Effect.Effect readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeDirectory: (directory: string) => Effect.Effect @@ -123,6 +124,13 @@ const layer: Layer.Layer + Deferred.await(entry.deferred).pipe(Effect.exit), + ) + return exits.filter(Exit.isSuccess).map((exit) => exit.value) + }) + const reload = (input: LoadInput): Effect.Effect => { const directory = FSUtil.resolve(input.directory) return Effect.uninterruptibleMask((restore) => @@ -193,6 +201,7 @@ const layer: Layer.Layer status.list().pipe(Effect.provideService(InstanceRef, ctx))) + return active.some((sessions) => sessions.size > 0) +}) + export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f78b99ef7d9b..90335908e325 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -50,6 +50,19 @@ describe("InstanceStore", () => { }), ) + it.live("lists loaded instance contexts", () => + Effect.gen(function* () { + const first = yield* tmpdirScoped({ git: true }) + const second = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + yield* store.load({ directory: first }) + yield* store.load({ directory: second }) + + expect((yield* store.list()).map((ctx) => ctx.directory).toSorted()).toEqual([first, second].toSorted()) + }), + ) + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts new file mode 100644 index 000000000000..fd146f8b74d3 --- /dev/null +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceStore } from "../../src/project/instance-store" +import { SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { awaitSessionsIdle } from "../../src/server/global-lifecycle" +import { tmpdirScoped } from "../fixture/fixture" +import { awaitWithTimeout, testEffect } from "../lib/effect" + +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) + +const it = testEffect( + LayerNode.compile(LayerNode.group([InstanceStore.node, SessionStatus.node, CrossSpawnSpawner.node]), [ + [InstanceStore.bootstrapNode, noopBootstrap], + ]), +) + +const sessionID = SessionID.make("ses_global_lifecycle") + +describe("awaitSessionsIdle", () => { + it.live("resolves when no instance has a busy session", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + yield* store.load({ directory: dir }) + + yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle blocked while idle") + }), + ) + + it.live("waits for a busy session to go idle", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + const status = yield* SessionStatus.Service + + yield* status.set(sessionID, { type: "busy" }).pipe(Effect.provideService(InstanceRef, ctx)) + + const blocked = yield* awaitSessionsIdle().pipe( + Effect.as(false), + Effect.timeoutOrElse({ duration: "500 millis", orElse: () => Effect.succeed(true) }), + ) + expect(blocked).toBe(true) + + yield* status.set(sessionID, { type: "idle" }).pipe(Effect.provideService(InstanceRef, ctx)) + yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle did not resolve after the session went idle") + }), + ) +})