fix(cli): surface reload failures and skip in-flight instances

The request directory's reload failure now propagates so callers still see a failed reboot; sibling failures stay logged and isolated. InstanceStore.list() only returns settled instances, so a reload is no longer blocked by another project's in-flight boot.
This commit is contained in:
marius-kilocode
2026-09-14 09:40:58 +02:00
parent 3a2c5d5c2e
commit 5461950191
4 changed files with 77 additions and 16 deletions
@@ -1,17 +1,29 @@
import { Effect } from "effect"
import { InstanceStore } from "@/project/instance-store"
/** Reload every loaded instance that belongs to a project. */
export const reloadProject = (store: InstanceStore.Interface, projectID: string): Effect.Effect<void> =>
/**
* Reload every loaded instance that belongs to a project.
*
* Failures for the directory the request came from propagate so callers still
* see a failed reboot. Failures for sibling instances are logged and skipped.
*/
export const reloadProject = (
store: InstanceStore.Interface,
projectID: string,
requestDirectory: string,
): Effect.Effect<void> =>
Effect.gen(function* () {
for (const ctx of yield* store.list()) {
if (String(ctx.project.id) !== projectID) continue
yield* store
.reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }),
),
)
const reload = store.reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project })
if (ctx.directory === requestDirectory) {
yield* reload
continue
}
yield* reload.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }),
),
)
}
})
@@ -29,7 +29,7 @@ export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "ins
}),
)
}
yield* reloadProject(store, String(ctx.project.id))
yield* reloadProject(store, String(ctx.project.id), ctx.directory)
return true
})
@@ -228,9 +228,18 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
// kilocode_change start - loaded instance contexts for project-scoped operations
const list = (): Effect.Effect<InstanceContext[]> =>
Effect.forEach([...cache.values()], (entry) => Deferred.await(entry.deferred).pipe(Effect.exit)).pipe(
Effect.map((exits) => exits.filter(Exit.isSuccess).map((exit) => exit.value)),
)
Effect.forEach([...cache.values()], (entry) =>
Deferred.isDone(entry.deferred).pipe(
Effect.flatMap((done) =>
done
? Deferred.await(entry.deferred).pipe(
Effect.exit,
Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : undefined)),
)
: Effect.succeed(undefined),
),
),
).pipe(Effect.map((contexts) => contexts.filter((ctx): ctx is InstanceContext => ctx !== undefined)))
// kilocode_change end
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
@@ -3,8 +3,9 @@ import { mkdtemp, realpath, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { Effect, Exit } from "effect"
import { reloadProject } from "@/kilocode/project/reload"
import type { InstanceContext } from "@/project/instance-context"
import type { Project } from "@/project/project"
import { InstanceStore } from "@/project/instance-store"
import { testEffect } from "../lib/effect"
@@ -24,6 +25,15 @@ const makeProject = (id: string, worktree: string): Project.Info => ({
time: { created: 0, updated: 0 },
})
const context = (dir: string, id: string): InstanceContext => ({
directory: dir,
worktree: dir,
project: makeProject(id, dir),
})
const stub = (contexts: InstanceContext[], reload: InstanceStore.Interface["reload"]) =>
({ list: () => Effect.succeed(contexts), reload }) as unknown as InstanceStore.Interface
describe("reloadProject", () => {
it.live("reloads every loaded instance of the project and leaves other projects alone", () =>
Effect.gen(function* () {
@@ -36,7 +46,7 @@ describe("reloadProject", () => {
const b = yield* store.load({ directory: dirB, worktree: dirB, project: makeProject("proj_reload_shared", dirB) })
const c = yield* store.load({ directory: dirC, worktree: dirC, project: makeProject("proj_reload_other", dirC) })
yield* reloadProject(store, "proj_reload_shared")
yield* reloadProject(store, "proj_reload_shared", dirA)
expect(yield* store.load({ directory: dirA })).not.toBe(a)
expect(yield* store.load({ directory: dirB })).not.toBe(b)
@@ -51,9 +61,39 @@ describe("reloadProject", () => {
const loaded = yield* store.load({ directory: dir, worktree: dir, project: makeProject("proj_reload_solo", dir) })
yield* reloadProject(store, "proj_reload_missing")
yield* reloadProject(store, "proj_reload_missing", dir)
expect(yield* store.load({ directory: dir })).toBe(loaded)
}),
)
it.live("propagates a failure for the request directory", () =>
Effect.gen(function* () {
const dirA = "/tmp/reload-request-a"
const dirB = "/tmp/reload-request-b"
const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")]
const store = stub(contexts, (input) =>
input.directory === dirA ? Effect.die(new Error("request reload failed")) : Effect.succeed(contexts[1]!),
)
const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
it.live("skips a failure for a sibling directory", () =>
Effect.gen(function* () {
const dirA = "/tmp/reload-sibling-a"
const dirB = "/tmp/reload-sibling-b"
const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")]
const store = stub(contexts, (input) =>
input.directory === dirB ? Effect.die(new Error("sibling reload failed")) : Effect.succeed(contexts[0]!),
)
const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit)
expect(Exit.isSuccess(exit)).toBe(true)
}),
)
})