Skip to content

Commit c8cf3a5

Browse files
Honakitlangton
andauthored
fix(server): stop form polling from booting idle locations (#59083)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
1 parent 61163cb commit c8cf3a5

6 files changed

Lines changed: 62 additions & 6 deletions

File tree

‎packages/core/src/effect/app-node-builder.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { buildLocationServiceMap } from "../location-services.js"
22
import { LocationServiceMap } from "../location-service-map.js"
33
import { LayerNode } from "@opencode/util/effect/layer-node"
44
import { makeGlobalNode } from "@opencode/util/effect/app-node"
5-
import { Effect, Layer } from "effect"
5+
import { Effect, Layer, RcMap } from "effect"
66
import { Instance } from "../instance/service.js"
77

88
const instances = makeGlobalNode({
@@ -13,6 +13,7 @@ const instances = makeGlobalNode({
1313
const locations = yield* LocationServiceMap.Service
1414
return Instance.Service.of({
1515
provide: (session) => Effect.provide(locations.get(session.location)),
16+
live: (session) => RcMap.has(locations.rcMap, LocationServiceMap.canonical(session.location)),
1617
})
1718
}),
1819
),

‎packages/core/src/instance/service.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface Interface {
1212
readonly provide: (
1313
session: Session.Info,
1414
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | Error, Exclude<R, Services>>
15+
/** Whether `provide` would reuse built or booting capabilities. Selectors without it are assumed live. */
16+
readonly live?: (session: Session.Info) => Effect.Effect<boolean>
1517
}
1618

1719
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}

‎packages/sdk/src/internal/instances.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { InstancePlugins } from "@opencode/core/plugin/instance"
77
import type { Session } from "@opencode/schema/session"
88
import { makeGlobalNode } from "@opencode/util/effect/app-node"
99
import type { LayerNode } from "@opencode/util/effect/layer-node"
10-
import { Context, Duration, Effect, Layer, LayerMap, Scope } from "effect"
10+
import { Context, Duration, Effect, Layer, LayerMap, RcMap, Scope } from "effect"
1111

1212
export interface Configuration {
1313
readonly plugins: InstancePlugins.List
@@ -101,7 +101,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
101101
),
102102
{ idleTimeToLive: Duration.infinity },
103103
)
104-
return Instance.Service.of({ provide })
104+
return Instance.Service.of({ provide, live: (session) => RcMap.has(instances.rcMap, key(session)) })
105105
}),
106106
)
107107
}

‎packages/server/src/middleware/form-location.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { LocationServiceMap } from "@opencode/core/location-services"
33
import { Session } from "@opencode/core/session"
44
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
55
import { Effect, Layer } from "effect"
6-
import { HttpRouter, HttpServerRequest } from "effect/http"
6+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"
77
import { HttpApiMiddleware } from "effect/http-api"
88
import { locationErrors, requestRef, sessionInfo, type LocationServices } from "../location"
99

@@ -21,7 +21,7 @@ export const formLocationLayer = Layer.effect(
2121
const instances = yield* Instance.Service
2222
const locations = yield* LocationServiceMap.Service
2323

24-
return FormLocationMiddleware.of((effect) =>
24+
return FormLocationMiddleware.of((effect, options) =>
2525
Effect.gen(function* () {
2626
const route = yield* HttpRouter.RouteContext
2727
if (route.params.sessionID === "global") {
@@ -33,6 +33,10 @@ export const formLocationLayer = Layer.effect(
3333
}
3434

3535
const session = yield* sessionInfo(sessions, route.params.sessionID)
36+
// Forms live in memory, so a Session without live capabilities has none pending. Listing must
37+
// not boot its Location: clients poll whole subagent trees, including deleted worktrees.
38+
if (options.endpoint.identifier === "session.form.list" && instances.live && !(yield* instances.live(session)))
39+
return HttpServerResponse.jsonUnsafe({ data: [] })
3640
return yield* effect.pipe(instances.provide(session), locationErrors)
3741
}),
3842
)

‎packages/server/test/fetch.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it
4242
"/api/model",
4343
"/api/integration",
4444
`/api/session/${session.id}/permission`,
45-
`/api/session/${session.id}/form`,
4645
"/api/session/global/form",
4746
]
4847
for (const endpoint of endpoints) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect } from "bun:test"
2+
import fs from "fs/promises"
3+
import { Effect } from "effect"
4+
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
5+
import { it } from "../../core/test/lib/effect"
6+
import { ServerFetch } from "../src/fetch"
7+
8+
it.live("lists no forms for a Session whose Location is not live, and pending forms once it is", () =>
9+
Effect.gen(function* () {
10+
const handler = yield* ServerFetch.make({
11+
app: { version: "test" },
12+
database: { path: ":memory:" },
13+
fs: { filewatcher: false },
14+
models: { fetch: false },
15+
})
16+
const request = (path: string, body?: unknown) =>
17+
Effect.promise(async () => {
18+
const response = await handler(
19+
new Request(`http://opencode.local${path}`, {
20+
method: body === undefined ? "GET" : "POST",
21+
headers: { "content-type": "application/json" },
22+
body: body === undefined ? undefined : JSON.stringify(body),
23+
}),
24+
)
25+
return { status: response.status, body: await response.json() }
26+
})
27+
const create = (directory: string) =>
28+
request("/api/session", { location: { directory } }).pipe(
29+
Effect.map((response) => (response.body as { data: { id: string } }).data.id),
30+
)
31+
32+
// A subagent whose worktree was deleted must not boot or 404.
33+
const deleted = yield* tmpdirScoped()
34+
const orphan = yield* create(deleted.path)
35+
yield* Effect.promise(() => fs.rm(deleted.path, { recursive: true, force: true }))
36+
expect(yield* request(`/api/session/${orphan}/form`)).toEqual({ status: 200, body: { data: [] } })
37+
38+
const directory = yield* tmpdirScoped()
39+
const live = yield* create(directory.path)
40+
const created = yield* request(`/api/session/${live}/form`, {
41+
title: "Pick one",
42+
fields: [{ key: "answer", type: "string" }],
43+
})
44+
expect(created.status).toBe(200)
45+
expect(yield* request(`/api/session/${live}/form`)).toEqual({
46+
status: 200,
47+
body: { data: [(created.body as { data: unknown }).data] },
48+
})
49+
}).pipe(Effect.scoped),
50+
)

0 commit comments

Comments
 (0)