From 3e5384bfe5716574cf0e1dc0018935cc3715d801 Mon Sep 17 00:00:00 2001 From: Nathan Ollerenshaw Date: Fri, 14 Aug 2026 10:31:32 -0700 Subject: [PATCH 1/4] fix(opencode): defer config reload until sessions are idle SIGUSR2 asks the TUI worker to reload config, which disposes every instance. Instance disposal cancels the session runners that instance owns, so a signal that lands while the model is streaming interrupts the run. Desktop environments send this signal on theme changes - Omarchy's omarchy-theme-set runs `killall -SIGUSR2 opencode` - so switching themes mid-run aborts the in-flight request. Theme refresh does not depend on the worker reload: the TUI re-detects the terminal palette and re-scans theme files from its own SIGUSR2 handler. Wait for every instance to have no busy session before invalidating config and disposing, and coalesce signals that arrive while waiting, so the reload is deferred rather than dropped. --- packages/opencode/src/cli/tui/worker.ts | 27 +++++++--- .../opencode/src/project/instance-store.ts | 9 ++++ .../opencode/src/server/global-lifecycle.ts | 22 ++++++++ .../opencode/test/project/instance.test.ts | 13 +++++ .../test/server/global-lifecycle.test.ts | 54 +++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/server/global-lifecycle.test.ts 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") + }), + ) +}) From ddfcf14fcc00cb66112076368078f49a34492ba4 Mon Sep 17 00:00:00 2001 From: Keith Hughitt Date: Mon, 14 Sep 2026 22:20:15 -0400 Subject: [PATCH 2/4] fix(opencode): run the deferred reload through one lifecycle effect Fold the idle wait, config invalidation, and disposal into a single reloadWhenSessionsIdle effect so the worker only coalesces overlapping signals and the whole path can be exercised in a test. Cover both the immediate reload when no session is busy and the deferred one, asserting on global.disposed and instance identity rather than on the wait helper. --- packages/opencode/src/cli/tui/worker.ts | 17 +--- .../opencode/src/server/global-lifecycle.ts | 30 +++--- .../test/server/global-lifecycle.test.ts | 94 ++++++++++++++----- 3 files changed, 91 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index ba0541c0be94..913e9372d3c8 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -2,14 +2,12 @@ import { Server } from "@/server/server" import { InstanceRuntime } from "@/project/instance-runtime" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" -import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -import { awaitSessionsIdle, disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { reloadWhenSessionsIdle } from "@/server/global-lifecycle" Heap.start() @@ -63,18 +61,9 @@ export const rpc = { }, async reload() { // 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. + // can land mid-run. Signals that arrive while one is pending join it. if (!reloading) { - reloading = AppRuntime.runPromise( - Effect.gen(function* () { - yield* awaitSessionsIdle() - const cfg = yield* Config.Service - yield* cfg.invalidate() - yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) - }), - ).finally(() => { + reloading = AppRuntime.runPromise(reloadWhenSessionsIdle()).finally(() => { reloading = undefined }) } diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index 8beda3e64f80..078757de913d 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -1,4 +1,5 @@ import { GlobalBus } from "@/bus/global" +import { Config } from "@/config/config" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { SessionStatus } from "@/session/status" @@ -28,23 +29,26 @@ export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.dispos ) // Disposing an instance cancels every session runner it owns, so a config -// reload that lands while the model is streaming aborts the run. Callers that -// reload on an external trigger (SIGUSR2, config writes) wait here first so the -// reload is deferred rather than dropped. -export const awaitSessionsIdle = Effect.fn("Server.awaitSessionsIdle")(function* () { - while (yield* sessionsBusy) { +// reload that lands while the model is streaming aborts the run. External +// reload triggers (SIGUSR2 from desktop theme hooks) wait here until every +// session is idle, so the reload is deferred rather than dropped. Background +// jobs are not part of the wait. +export const reloadWhenSessionsIdle = Effect.fn("Server.reloadWhenSessionsIdle")(function* () { + const store = yield* InstanceStore.Service + const status = yield* SessionStatus.Service + const config = yield* Config.Service + while (true) { + const instances = yield* store.list() + const active = yield* Effect.forEach(instances, (ctx) => + status.list().pipe(Effect.provideService(InstanceRef, ctx)), + ) + if (!active.some((sessions) => sessions.size > 0)) break yield* Effect.sleep(IDLE_POLL_INTERVAL) } + yield* config.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) }) const IDLE_POLL_INTERVAL = "250 millis" -const sessionsBusy = Effect.gen(function* () { - const store = yield* InstanceStore.Service - const status = yield* SessionStatus.Service - const instances = yield* store.list() - const active = yield* Effect.forEach(instances, (ctx) => 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/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts index fd146f8b74d3..9a5dc66f5d3a 100644 --- a/packages/opencode/test/server/global-lifecycle.test.ts +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -1,54 +1,102 @@ 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 { FSUtil } from "@opencode-ai/core/fs-util" +import { Npm } from "@opencode-ai/core/npm" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" +import { Effect, Fiber, Layer } from "effect" +import { HttpClient } from "effect/unstable/http" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Account } from "@/account/account" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { Env } from "@/env" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" +import { reloadWhenSessionsIdle } from "@/server/global-lifecycle" +import { SessionID } from "@/session/schema" +import { SessionStatus } from "@/session/status" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const unexpectedHttp = HttpClient.make((request) => + Effect.die(`unexpected http request: ${request.method} ${request.url}`), +) const it = testEffect( - LayerNode.compile(LayerNode.group([InstanceStore.node, SessionStatus.node, CrossSpawnSpawner.node]), [ - [InstanceStore.bootstrapNode, noopBootstrap], - ]), + LayerNode.compile( + LayerNode.group([ + InstanceStore.node, + SessionStatus.node, + Config.node, + FSUtil.node, + Env.node, + CrossSpawnSpawner.node, + ]), + [ + [InstanceStore.bootstrapNode, noopBootstrap], + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, Layer.succeed(HttpClient.HttpClient, unexpectedHttp)], + ], + ), ) const sessionID = SessionID.make("ses_global_lifecycle") -describe("awaitSessionsIdle", () => { - it.live("resolves when no instance has a busy session", () => +const collectGlobalDisposed = () => + Effect.acquireRelease( + Effect.sync(() => { + const events: GlobalEvent[] = [] + const handler = (event: GlobalEvent) => { + if (event.payload?.type === "global.disposed") events.push(event) + } + GlobalBus.on("event", handler) + return { events, handler } + }), + ({ handler }) => Effect.sync(() => GlobalBus.off("event", handler)), + ).pipe(Effect.map(({ events }) => events)) + +describe("reloadWhenSessionsIdle", () => { + it.live("disposes instances and emits global.disposed when no session is busy", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service - yield* store.load({ directory: dir }) + const before = yield* store.load({ directory: dir }) + const disposed = yield* collectGlobalDisposed() + + yield* awaitWithTimeout(reloadWhenSessionsIdle(), "reload blocked while no session was busy") - yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle blocked while idle") + expect(disposed).toHaveLength(1) + expect(yield* store.load({ directory: dir })).not.toBe(before) }), ) - it.live("waits for a busy session to go idle", () => + it.live("defers disposal until the busy session goes 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 - + const ctx = yield* store.load({ directory: dir }) + const disposed = yield* collectGlobalDisposed() 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) + const reload = yield* reloadWhenSessionsIdle().pipe(Effect.forkScoped({ startImmediately: true })) + yield* Effect.sleep("600 millis") + expect(disposed).toHaveLength(0) + expect(yield* store.load({ directory: dir })).toBe(ctx) yield* status.set(sessionID, { type: "idle" }).pipe(Effect.provideService(InstanceRef, ctx)) - yield* awaitWithTimeout(awaitSessionsIdle(), "awaitSessionsIdle did not resolve after the session went idle") + yield* awaitWithTimeout(Fiber.join(reload), "reload did not run after the session went idle") + + expect(disposed).toHaveLength(1) + expect(yield* store.load({ directory: dir })).not.toBe(ctx) }), ) }) From 3b4d836d3509f109dea4e330771d76a53a118a19 Mon Sep 17 00:00:00 2001 From: Keith Hughitt Date: Tue, 15 Sep 2026 06:30:00 -0400 Subject: [PATCH 3/4] fix(opencode): include instances that load during the idle check InstanceStore.list snapshotted the cache before awaiting each entry's boot, so an instance that loaded while an earlier one was still booting was missing from the idle check but present in the disposal that followed. Re-snapshot after the await and repeat until the set is stable. Add a gated-bootstrap regression test for the case. --- .../opencode/src/project/instance-store.ts | 14 +++-- .../test/server/global-lifecycle.test.ts | 55 ++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index c9188d9b0e0b..f922e6dbd3ad 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -124,11 +124,17 @@ const layer: Layer.Layer - Deferred.await(entry.deferred).pipe(Effect.exit), - ) - return exits.filter(Exit.isSuccess).map((exit) => exit.value) + while (true) { + const entries = [...cache.values()] + const exits = yield* Effect.forEach(entries, (entry) => Deferred.await(entry.deferred).pipe(Effect.exit)) + const current = [...cache.values()] + if (current.length === entries.length && current.every((entry, index) => entry === entries[index])) { + return exits.filter(Exit.isSuccess).map((exit) => exit.value) + } + } }) const reload = (input: LoadInput): Effect.Effect => { diff --git a/packages/opencode/test/server/global-lifecycle.test.ts b/packages/opencode/test/server/global-lifecycle.test.ts index 9a5dc66f5d3a..8a880e52c79f 100644 --- a/packages/opencode/test/server/global-lifecycle.test.ts +++ b/packages/opencode/test/server/global-lifecycle.test.ts @@ -4,7 +4,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" import { Npm } from "@opencode-ai/core/npm" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { Effect, Fiber, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Account } from "@/account/account" @@ -23,7 +23,22 @@ import { NpmTest } from "../fake/npm" import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) + +const setBootstrap = (run: Effect.Effect) => + Effect.acquireRelease( + Effect.sync(() => { + bootstrapRun = run + }), + () => + Effect.sync(() => { + bootstrapRun = Effect.void + }), + ) const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) @@ -99,4 +114,40 @@ describe("reloadWhenSessionsIdle", () => { expect(yield* store.load({ directory: dir })).not.toBe(ctx) }), ) + + it.live("waits for a session on an instance that loaded while another was still booting", () => + Effect.gen(function* () { + const slow = yield* tmpdirScoped({ git: true }) + const fast = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const status = yield* SessionStatus.Service + const started = yield* Deferred.make() + const release = yield* Deferred.make() + yield* setBootstrap( + Effect.gen(function* () { + if ((yield* InstanceRef)?.directory !== slow) return + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + }), + ) + const disposed = yield* collectGlobalDisposed() + + yield* store.load({ directory: slow }).pipe(Effect.forkScoped({ startImmediately: true })) + yield* Deferred.await(started) + const reload = yield* reloadWhenSessionsIdle().pipe(Effect.forkScoped({ startImmediately: true })) + const ctx = yield* store.load({ directory: fast }) + yield* status.set(sessionID, { type: "busy" }).pipe(Effect.provideService(InstanceRef, ctx)) + yield* Deferred.succeed(release, undefined) + + yield* Effect.sleep("600 millis") + expect(disposed).toHaveLength(0) + expect(yield* store.load({ directory: fast })).toBe(ctx) + + yield* status.set(sessionID, { type: "idle" }).pipe(Effect.provideService(InstanceRef, ctx)) + yield* awaitWithTimeout(Fiber.join(reload), "reload did not run after the session went idle") + + expect(disposed).toHaveLength(1) + expect(yield* store.load({ directory: fast })).not.toBe(ctx) + }), + ) }) From 42ffdb7a18d6c3cc62c0308d1098004f389ae95c Mon Sep 17 00:00:00 2001 From: Keith Hughitt Date: Tue, 15 Sep 2026 09:37:48 -0400 Subject: [PATCH 4/4] fix(opencode): log deferred reloads and document InstanceStore.list Log once when a reload starts waiting on busy sessions, with the count, and again when it proceeds, so a config change that seems ignored can be traced to a run still in progress. Note on InstanceStore.list that it awaits booting entries and omits ones whose boot failed. --- packages/opencode/src/project/instance-store.ts | 1 + packages/opencode/src/server/global-lifecycle.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index f922e6dbd3ad..4740e5df27cd 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 + /** Loaded instances only: entries still booting are awaited, entries whose boot failed are omitted. */ readonly list: () => Effect.Effect readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index 078757de913d..61b77e06883d 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -37,14 +37,19 @@ export const reloadWhenSessionsIdle = Effect.fn("Server.reloadWhenSessionsIdle") const store = yield* InstanceStore.Service const status = yield* SessionStatus.Service const config = yield* Config.Service + let deferred = false while (true) { const instances = yield* store.list() const active = yield* Effect.forEach(instances, (ctx) => status.list().pipe(Effect.provideService(InstanceRef, ctx)), ) - if (!active.some((sessions) => sessions.size > 0)) break + const sessions = active.reduce((count, item) => count + item.size, 0) + if (sessions === 0) break + if (!deferred) yield* Effect.logInfo("deferring reload until sessions are idle", { sessions }) + deferred = true yield* Effect.sleep(IDLE_POLL_INTERVAL) } + if (deferred) yield* Effect.logInfo("sessions idle, reloading") yield* config.invalidate() yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) })