diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index 505c96231a..42cd02e091 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -4,6 +4,7 @@ import { Workspace } from "@opencode-ai/schema/workspace" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { eq } from "drizzle-orm" import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect" +import { systemError } from "effect/PlatformError" import { make as makeSpawner } from "effect/unstable/process/ChildProcessSpawner" import type { Driver as EnvironmentDriver } from "./environment/driver" import { Database } from "./database/database" @@ -169,7 +170,17 @@ const layer = (options: Options) => Effect.acquireRelease( locks.withLock(workspaceID)( Effect.gen(function* () { - const connection = yield* open(workspaceID).pipe(Effect.orDie) + const connection = yield* open(workspaceID).pipe( + Effect.mapError((cause) => + systemError({ + _tag: "Unknown", + module: "Workspace", + method: "spawn", + description: `Failed to wake workspace ${workspaceID}`, + cause, + }), + ), + ) yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis) yield* Ref.update(connection.active, (active) => active + 1) return connection @@ -184,6 +195,7 @@ const layer = (options: Options) => ), ).pipe(Effect.flatMap((connection) => connection.environment.spawner.spawn(command))), ) + // Overrides are connection-bound; route them per spawn before any workspace driver ships them. return { spawner, overrides: initial.environment.overrides } }), destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) { diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index e39ad33b09..78ec2666bc 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -15,6 +15,7 @@ import { testEffect } from "./lib/effect" const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = [] const memory = makeMemoryDriver() +let failConnect = false const driver = WorkspaceDriver.make({ create: ({ workspaceID }) => { @@ -23,6 +24,7 @@ const driver = WorkspaceDriver.make({ }, connect: ({ binding }) => { calls.push({ operation: "connect", binding }) + if (failConnect) return Effect.fail(new WorkspaceDriver.Error({ message: "wake failed" })) return Effect.succeed(memory) }, suspendForIdle: ({ binding, saveBinding }) => { @@ -51,7 +53,10 @@ const it = testEffect( ), ) -beforeEach(() => calls.splice(0)) +beforeEach(() => { + calls.splice(0) + failConnect = false +}) it.effect("persists the workspace lifecycle and reconnects after idle suspension", () => Effect.gen(function* () { @@ -97,3 +102,25 @@ it.effect("roundtrips nullable bindings through the workspace table", () => expect(row).toEqual({ id, provider: "fake", binding: null, created_at: 10, last_used_at: 20 }) }), ) + +it.effect("surfaces wake failures through the spawn error channel", () => + Effect.gen(function* () { + const workspace = yield* Workspace.Service + const created = yield* workspace.create("fake") + const environment = yield* workspace.connect(created.id) + + yield* TestClock.adjust("6 minutes") + failConnect = true + + const error = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("wake"))).pipe(Effect.flip) + expect(error).toMatchObject({ + _tag: "PlatformError", + reason: { + _tag: "Unknown", + module: "Workspace", + method: "spawn", + description: `Failed to wake workspace ${created.id}`, + }, + }) + }), +)