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

refactor(lifecycle): bootstrap as pure orchestration (#25510)

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

+ 4 - 2
packages/opencode/src/file/watcher.ts

@@ -123,7 +123,9 @@ export const layer = Layer.effect(
           const cfgIgnores = cfg.watcher?.ignore ?? []
 
           if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-            yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
+            yield* Effect.forkScoped(
+              subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
+            )
           }
 
           if (ctx.project.vcs === "git") {
@@ -135,7 +137,7 @@ export const layer = Layer.effect(
               const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
                 (entry) => entry !== "HEAD",
               )
-              yield* subscribe(vcsDir, ignore)
+              yield* Effect.forkScoped(subscribe(vcsDir, ignore))
             }
           }
         },

+ 7 - 11
packages/opencode/src/project/bootstrap.ts

@@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot"
 import * as Project from "./project"
 import * as Vcs from "./vcs"
 import { Bus } from "../bus"
-import { Command } from "../command"
 import { InstanceState } from "@/effect/instance-state"
 import { FileWatcher } from "@/file/watcher"
 import { ShareNext } from "@/share/share-next"
@@ -23,13 +22,13 @@ export const layer = Layer.effect(
     // Yield each bootstrap dep at layer init so `run` itself has R = never.
     // InstanceStore imports only the lightweight tag from bootstrap-service.ts,
     // so it can depend on bootstrap without importing this implementation graph.
-    const bus = yield* Bus.Service
     const config = yield* Config.Service
     const file = yield* File.Service
     const fileWatcher = yield* FileWatcher.Service
     const format = yield* Format.Service
     const lsp = yield* LSP.Service
     const plugin = yield* Plugin.Service
+    const project = yield* Project.Service
     const shareNext = yield* ShareNext.Service
     const snapshot = yield* Snapshot.Service
     const vcs = yield* Vcs.Service
@@ -41,16 +40,13 @@ export const layer = Layer.effect(
       yield* config.get()
       // Plugin can mutate config so it has to be initialized before anything else.
       yield* plugin.init()
-      yield* Effect.all(
-        [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
+      // Each service self-manages its own slow work via Effect.forkScoped against
+      // its per-instance state scope. We just await materialization here.
+      yield* Effect.forEach(
+        [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
+        (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
+        { concurrency: "unbounded", discard: true },
       ).pipe(Effect.withSpan("InstanceBootstrap.init"))
-
-      const projectID = ctx.project.id
-      yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
-        if (payload.properties.name === Command.Default.INIT) {
-          Project.setInitialized(projectID)
-        }
-      })
     }).pipe(Effect.withSpan("InstanceBootstrap"))
 
     return Service.of({ run })

+ 28 - 1
packages/opencode/src/project/project.ts

@@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 import { which } from "../util/which"
 import { ProjectID } from "./schema"
+import { Bus } from "@/bus"
+import { Command } from "@/command"
+import { InstanceState } from "@/effect/instance-state"
 import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { NodePath } from "@effect/platform-node"
@@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
 // ---------------------------------------------------------------------------
 
 export interface Interface {
+  /**
+   * Per-instance setup. Subscribes to the `/init` slash command for the
+   * current instance and stamps the project's initialized timestamp when it
+   * fires. Subscription lifetime is tied to the per-instance state scope.
+   */
+  readonly init: () => Effect.Effect<void>
   readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
   readonly discover: (input: Info) => Effect.Effect<void>
   readonly list: () => Effect.Effect<Info[]>
@@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string }
 export const layer: Layer.Layer<
   Service,
   never,
-  AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+  AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
 > = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
     const pathSvc = yield* Path.Path
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const bus = yield* Bus.Service
 
     const git = Effect.fnUntraced(
       function* (args: string[], opts?: { cwd?: string }) {
@@ -417,6 +427,21 @@ export const layer: Layer.Layer<
       )
     })
 
+    const initState = yield* InstanceState.make(
+      Effect.fn("Project.initState")(function* (ctx) {
+        yield* bus.subscribe(Command.Event.Executed).pipe(
+          Stream.runForEach((payload) =>
+            payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
+          ),
+          Effect.forkScoped,
+        )
+      }),
+    )
+
+    const init = Effect.fn("Project.init")(function* () {
+      yield* InstanceState.get(initState)
+    })
+
     const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
       const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
       if (!row) return []
@@ -466,6 +491,7 @@ export const layer: Layer.Layer<
     })
 
     return Service.of({
+      init,
       fromDirectory,
       discover,
       list,
@@ -481,6 +507,7 @@ export const layer: Layer.Layer<
 )
 
 export const defaultLayer = layer.pipe(
+  Layer.provide(Bus.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(NodePath.layer),

+ 2 - 0
packages/opencode/test/project/project.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, test } from "bun:test"
+import { Bus } from "@/bus"
 import { Project } from "@/project/project"
 import * as Log from "@opencode-ai/core/util/log"
 import { $ } from "bun"
@@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) {
 function projectLayerWithFailure(failArg: string) {
   return Project.layer.pipe(
     Layer.provide(mockGitFailure(failArg)),
+    Layer.provide(Bus.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(NodePath.layer),
   )