From 30d90204b2b1c4cd52bffb211312306680929425 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 11:24:55 -0400 Subject: [PATCH 1/3] fix(worktree): fork workspace worktree boot --- packages/opencode/src/worktree/index.ts | 11 +- .../server/worktree-endpoint-repro.test.ts | 106 ++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/server/worktree-endpoint-repro.test.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 43453b561a8a..f4e4d2721ceb 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -291,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 000000000000..9324ae772c77 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, +} + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const server = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true }) + return { + request: (input: string, init?: RequestInit) => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + dispose: server.dispose, + [Symbol.asyncDispose]: server.dispose, + } +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() +}) + +describe("worktree endpoint reproduction", () => { + async function setProjectStartCommand(input: { request: ReturnType["request"]; directory: string; command: string }) { + const current = await input.request(`/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (await current.json()) as { id: string } + const updated = await input.request(`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }) + expect(updated.status).toBe(200) + } + + test("direct HttpApi worktree create returns without waiting for boot", async () => { + await using tmp = await tmpdir({ git: true }) + await using server = app() + + const response = await withTimeout( + server.request(`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + 5_000, + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ directory: expect.any(String) }) + }) + + test("workspace worktree create does not hang", async () => { + await using tmp = await tmpdir({ git: true }) + await using server = app() + + const response = await withTimeout( + server.request(`${WorkspacePaths.list}?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + 8_000, + "workspace worktree create", + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ type: "worktree", directory: expect.any(String) }) + }) + + test("workspace worktree create returns without waiting for project start command", async () => { + await using tmp = await tmpdir({ git: true }) + await using server = app() + await setProjectStartCommand({ + request: server.request, + directory: tmp.path, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = await withTimeout( + server.request(`${WorkspacePaths.list}?directory=${encodeURIComponent(tmp.path)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + 6_000, + "workspace worktree create with project start command", + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }) +}) From 2851e632dc7bbb7526e78d4644cd570a489303c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 11:48:04 -0400 Subject: [PATCH 2/3] test(server): use effect fixtures for worktree endpoint repro --- .../server/worktree-endpoint-repro.test.ts | 202 +++++++++++------- 1 file changed, 122 insertions(+), 80 deletions(-) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 9324ae772c77..768a261a0058 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" @@ -6,101 +7,142 @@ import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/grou import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { withTimeout } from "../../src/util/timeout" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) } -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - const server = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true }) - return { - request: (input: string, init?: RequestInit) => - server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), - dispose: server.dispose, - [Symbol.asyncDispose]: server.dispose, - } +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) } -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES - await resetDatabase() -}) +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} -describe("worktree endpoint reproduction", () => { - async function setProjectStartCommand(input: { request: ReturnType["request"]; directory: string; command: string }) { - const current = await input.request(`/project/current?directory=${encodeURIComponent(input.directory)}`) +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) expect(current.status).toBe(200) - const project = (await current.json()) as { id: string } - const updated = await input.request(`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ commands: { start: input.command } }), - }) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) expect(updated.status).toBe(200) - } + }) +} - test("direct HttpApi worktree create returns without waiting for boot", async () => { - await using tmp = await tmpdir({ git: true }) - await using server = app() +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() - const response = await withTimeout( - server.request(`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(tmp.path)}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) }), - 5_000, - "direct worktree create", - ) + { git: true }, + ) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ directory: expect.any(String) }) - }) + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() - test("workspace worktree create does not hang", async () => { - await using tmp = await tmpdir({ git: true }) - await using server = app() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) - const response = await withTimeout( - server.request(`${WorkspacePaths.list}?directory=${encodeURIComponent(tmp.path)}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "worktree", branch: null }), + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) }), - 8_000, - "workspace worktree create", - ) + { git: true }, + ) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ type: "worktree", directory: expect.any(String) }) - }) + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) - test("workspace worktree create returns without waiting for project start command", async () => { - await using tmp = await tmpdir({ git: true }) - await using server = app() - await setProjectStartCommand({ - request: server.request, - directory: tmp.path, - command: 'bun -e "setTimeout(() => {}, 2000)"', - }) - - const started = Date.now() - const response = await withTimeout( - server.request(`${WorkspacePaths.list}?directory=${encodeURIComponent(tmp.path)}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "worktree", branch: null }), - }), - 6_000, - "workspace worktree create with project start command", - ) + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) - expect(response.status).toBe(200) - expect(Date.now() - started).toBeLessThan(1_500) - }) + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) }) From 54fa8d5a390676931ae42c6f086af9a23664a004 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 11:55:54 -0400 Subject: [PATCH 3/3] test(worktree): expect async boot from createFromInfo --- packages/opencode/test/project/worktree.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index a89fda6ca5c1..b191a3c9523f 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -178,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -191,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true },