Просмотр исходного кода

fix(worktree): fork workspace worktree boot (#25723)

Kit Langton 4 месяцев назад
Родитель
Сommit
25dc6f09bc

+ 5 - 6
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
     })
 

+ 3 - 1
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 },

+ 148 - 0
packages/opencode/test/server/worktree-endpoint-repro.test.ts

@@ -0,0 +1,148 @@
+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"
+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 { TestInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+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<typeof HttpRouter.toWebHandler>
+
+function serverScoped() {
+  return Effect.acquireRelease(
+    Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
+    (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
+  )
+}
+
+function request(server: TestServer, input: string, init?: RequestInit) {
+  return Effect.promise(() =>
+    server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
+  )
+}
+
+function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
+  return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
+}
+
+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 = (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)
+  })
+}
+
+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 = 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) })
+      }),
+    { git: true },
+  )
+
+  it.instance(
+    "workspace worktree create does not hang",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const server = yield* serverScoped()
+
+        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,
+        )
+
+        expect(response.status).toBe(200)
+        expect(yield* Effect.promise(() => response.json())).toMatchObject({
+          type: "worktree",
+          directory: expect.any(String),
+        })
+      }),
+    { git: true },
+  )
+
+  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)"',
+        })
+
+        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)
+      }),
+    { git: true },
+  )
+})