diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 97745b41bf81..fdbc3ccbd3a0 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -90,7 +90,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { - operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]), + operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]), }, ) {} @@ -197,6 +197,7 @@ export const layer = Layer.unwrap( ), ) + const result = Service.of({ create: Effect.fn("V2Session.create")(function* (input) { const sessionID = input.id ?? SessionSchema.ID.create() @@ -401,7 +402,7 @@ export const layer = Layer.unwrap( }), wait: Effect.fn("V2Session.wait")(function* (sessionID) { yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "wait" }) + yield* execution.wait(sessionID) }), active: execution.active, resume: Effect.fn("V2Session.resume")(function* (sessionID) { diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index f92929e6ee9d..7aa656898675 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -11,6 +11,8 @@ export interface Interface { readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect /** Registers newly recorded work. Repeated wakeups may coalesce. */ readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect + /** Waits for active work owned by this process to finish. Idle wait is a no-op. */ + readonly wait: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } @@ -25,6 +27,7 @@ export const noopLayer = Layer.succeed( active: Effect.succeed(new Set()), resume: () => Effect.void, wake: () => Effect.void, + wait: () => Effect.void, interrupt: () => Effect.void, }), ) diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index bd1be745393e..d093c31ec805 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -32,6 +32,7 @@ export const layer = Layer.effect( interrupt: coordinator.interrupt, resume: coordinator.run, wake: coordinator.wake, + wait: coordinator.wait, }) }), ) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 2f89aff9e3d2..fcaee2f34429 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -10,6 +10,8 @@ export interface Coordinator { readonly run: (key: Key) => Effect.Effect /** Registers one coalesced follow-up after newly recorded work. */ readonly wake: (key: Key) => Effect.Effect + /** Waits for active execution to finish without starting new work. */ + readonly wait: (key: Key) => Effect.Effect /** Stops active execution and waits for its cleanup. */ readonly interrupt: (key: Key) => Effect.Effect } @@ -91,6 +93,13 @@ export const make = (options: { start(key, next, false) }) + const wait = (key: Key): Effect.Effect => + Effect.suspend(() => { + const entry = active.get(key) + if (entry === undefined) return Effect.void + return Effect.ignore(Deferred.await(entry.done)).pipe(Effect.andThen(wait(key))) + }) + const interrupt = (key: Key): Effect.Effect => Effect.suspend(() => { const entry = active.get(key) @@ -100,5 +109,5 @@ export const make = (options: { return Fiber.interrupt(entry.owner) }) - return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt } + return { active: Effect.sync(() => new Set(active.keys())), run, wake, wait, interrupt } }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 9ff0e2249034..2622e54c9bfc 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -35,6 +35,7 @@ const execution = Layer.succeed( Effect.sync(() => { interruptCalls.push(sessionID) }), + wait: () => Effect.void, wake: (sessionID) => Effect.sync(() => { wakeCalls.push(sessionID) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index dfbeda664c54..3ffd98aeb5f2 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -66,6 +66,33 @@ describe("SessionRunCoordinator", () => { ), ) + it.effect("waits for active execution without starting new work", () => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make() + const gate = yield* Deferred.make() + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ + drain: () => + Effect.sync(() => runs++).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(gate)), + ), + }) + + yield* coordinator.wake("session") + yield* Deferred.await(started) + const waiter = yield* coordinator.wait("session").pipe(Effect.forkChild) + yield* Effect.yieldNow + + expect(runs).toBe(1) + yield* Deferred.succeed(gate, undefined) + yield* Fiber.join(waiter) + expect(runs).toBe(1) + }), + ), + ) + it.effect("snapshots only active executions", () => Effect.scoped( Effect.gen(function* () { @@ -99,6 +126,19 @@ describe("SessionRunCoordinator", () => { ), ) + it.effect("does nothing when waiting while idle", () => + Effect.scoped( + Effect.gen(function* () { + let runs = 0 + const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) }) + + yield* coordinator.wait("session") + + expect(runs).toBe(0) + }), + ), + ) + it.effect("cleans active executions after failure and defect", () => Effect.scoped( Effect.gen(function* () { @@ -138,6 +178,7 @@ describe("SessionRunCoordinator", () => { }), ) + it.effect("coalesces wakes received during active execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index da8699fbd2ff..3490ddafe98c 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -98,6 +98,7 @@ const execution = Layer.effect( active: coordinator.active, resume: coordinator.run, wake: coordinator.wake, + wait: coordinator.wait, interrupt: coordinator.interrupt, }) }), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c9594e8244f1..271d5f8e65b5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -257,6 +257,7 @@ const execution = Layer.effect( active: coordinator.active, resume: coordinator.run, wake: coordinator.wake, + wait: coordinator.wait, interrupt: coordinator.interrupt, }) }), diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index a8f6f8d1c855..012bdea49e7d 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -220,10 +220,7 @@ describe("PublicApi OpenAPI v2 errors", () => { test("documents v2 unfinished session mutation errors", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec - for (const route of [ - ["post", "/api/session/{sessionID}/compact"], - ["post", "/api/session/{sessionID}/wait"], - ] as const) { + for (const route of [["post", "/api/session/{sessionID}/compact"]] as const) { expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe( "ServiceUnavailableError", ) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 828ed3373a97..16fa27ce6a00 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -645,7 +645,7 @@ describe("session HttpApi", () => { ) it.instance( - "returns v2 public unavailable errors for unfinished session mutations", + "returns v2 public unavailable errors for unfinished session mutations and waits idle sessions", () => Effect.gen(function* () { const test = yield* TestInstance @@ -661,12 +661,7 @@ describe("session HttpApi", () => { }) const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) - expect(wait.status).toBe(503) - expect(yield* responseJson(wait)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session wait is not available yet", - service: "session.wait", - }) + expect(wait.status).toBe(204) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index f1f908e6ac88..8b0211f1a44e 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -227,7 +227,7 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", { params: { sessionID: Session.ID }, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError, ServiceUnavailableError], + error: SessionNotFoundError, }) .middleware(sessionLocationMiddleware) .annotateMerge( diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 602673e1ff77..b1ff9863d4d6 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -204,14 +204,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), - Effect.catchTag("Session.OperationUnavailableError", (error) => - Effect.fail( - new ServiceUnavailableError({ - message: `Session ${error.operation} is not available yet`, - service: `session.${error.operation}`, - }), - ), - ), ) return HttpApiSchema.NoContent.make() }),