Skip to content

Commit dcb56d6

Browse files
authored
fix(worktree): fork workspace worktree boot (anomalyco#25723)
1 parent b639442 commit dcb56d6

3 files changed

Lines changed: 156 additions & 7 deletions

File tree

packages/opencode/src/worktree/index.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -291,16 +291,15 @@ export const layer: Layer.Layer<
291291

292292
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
293293
yield* setup(info)
294-
yield* boot(info, startCommand)
294+
yield* boot(info, startCommand).pipe(
295+
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
296+
Effect.forkIn(scope),
297+
)
295298
})
296299

297300
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
298301
const info = yield* makeWorktreeInfo(input?.name)
299-
yield* setup(info)
300-
yield* boot(info, input?.startCommand).pipe(
301-
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
302-
Effect.forkIn(scope),
303-
)
302+
yield* createFromInfo(info, input?.startCommand)
304303
return info
305304
})
306305

packages/opencode/test/project/worktree.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,19 +178,21 @@ describe("Worktree", () => {
178178
})
179179

180180
describe("createFromInfo", () => {
181-
wintest("creates and bootstraps git worktree", () =>
181+
wintest("creates git worktree and boots asynchronously", () =>
182182
provideTmpdirInstance(
183183
(dir) =>
184184
Effect.gen(function* () {
185185
const svc = yield* Worktree.Service
186186
const info = yield* svc.makeWorktreeInfo("from-info-test")
187+
const ready = waitReady()
187188
yield* svc.createFromInfo(info)
188189

189190
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
190191
const normalizedList = list.replace(/\\/g, "/")
191192
const normalizedDir = info.directory.replace(/\\/g, "/")
192193
expect(normalizedList).toContain(normalizedDir)
193194

195+
yield* Effect.promise(() => ready)
194196
yield* svc.remove({ directory: info.directory })
195197
}),
196198
{ git: true },
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import { HttpRouter } from "effect/unstable/http"
4+
import { Flag } from "@opencode-ai/core/flag/flag"
5+
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
6+
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
7+
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
8+
import { withTimeout } from "../../src/util/timeout"
9+
import { resetDatabase } from "../fixture/db"
10+
import { TestInstance } from "../fixture/fixture"
11+
import { testEffect } from "../lib/effect"
12+
13+
const stateLayer = Layer.effectDiscard(
14+
Effect.gen(function* () {
15+
const original = {
16+
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
17+
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
18+
}
19+
20+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
21+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
22+
23+
yield* Effect.addFinalizer(() =>
24+
Effect.promise(async () => {
25+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
26+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
27+
await resetDatabase()
28+
}),
29+
)
30+
}),
31+
)
32+
33+
const it = testEffect(stateLayer)
34+
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
35+
36+
function serverScoped() {
37+
return Effect.acquireRelease(
38+
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
39+
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
40+
)
41+
}
42+
43+
function request(server: TestServer, input: string, init?: RequestInit) {
44+
return Effect.promise(() =>
45+
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
46+
)
47+
}
48+
49+
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
50+
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
51+
}
52+
53+
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
54+
return Effect.gen(function* () {
55+
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
56+
expect(current.status).toBe(200)
57+
const project = (yield* Effect.promise(() => current.json())) as { id: string }
58+
const updated = yield* request(
59+
input.server,
60+
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
61+
{
62+
method: "PATCH",
63+
headers: { "content-type": "application/json" },
64+
body: JSON.stringify({ commands: { start: input.command } }),
65+
},
66+
)
67+
expect(updated.status).toBe(200)
68+
})
69+
}
70+
71+
describe("worktree endpoint reproduction", () => {
72+
it.instance(
73+
"direct HttpApi worktree create returns without waiting for boot",
74+
() =>
75+
Effect.gen(function* () {
76+
const test = yield* TestInstance
77+
const server = yield* serverScoped()
78+
79+
const response = yield* withRequestTimeout(
80+
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
81+
method: "POST",
82+
headers: { "content-type": "application/json" },
83+
body: JSON.stringify({}),
84+
}),
85+
"direct worktree create",
86+
)
87+
88+
expect(response.status).toBe(200)
89+
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
90+
}),
91+
{ git: true },
92+
)
93+
94+
it.instance(
95+
"workspace worktree create does not hang",
96+
() =>
97+
Effect.gen(function* () {
98+
const test = yield* TestInstance
99+
const server = yield* serverScoped()
100+
101+
const response = yield* withRequestTimeout(
102+
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
103+
method: "POST",
104+
headers: { "content-type": "application/json" },
105+
body: JSON.stringify({ type: "worktree", branch: null }),
106+
}),
107+
"workspace worktree create",
108+
8_000,
109+
)
110+
111+
expect(response.status).toBe(200)
112+
expect(yield* Effect.promise(() => response.json())).toMatchObject({
113+
type: "worktree",
114+
directory: expect.any(String),
115+
})
116+
}),
117+
{ git: true },
118+
)
119+
120+
it.instance(
121+
"workspace worktree create returns without waiting for project start command",
122+
() =>
123+
Effect.gen(function* () {
124+
const test = yield* TestInstance
125+
const server = yield* serverScoped()
126+
yield* setProjectStartCommand({
127+
server,
128+
directory: test.directory,
129+
command: 'bun -e "setTimeout(() => {}, 2000)"',
130+
})
131+
132+
const started = Date.now()
133+
const response = yield* withRequestTimeout(
134+
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
135+
method: "POST",
136+
headers: { "content-type": "application/json" },
137+
body: JSON.stringify({ type: "worktree", branch: null }),
138+
}),
139+
"workspace worktree create with project start command",
140+
6_000,
141+
)
142+
143+
expect(response.status).toBe(200)
144+
expect(Date.now() - started).toBeLessThan(1_500)
145+
}),
146+
{ git: true },
147+
)
148+
})

0 commit comments

Comments
 (0)