From 16f8e7ef7fbd47755395539e7df54af3baae0c63 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 10:34:40 +0200 Subject: [PATCH 1/6] fix(cli): keep session reverts atomic --- .changeset/atomic-session-revert.md | 6 + .../opencode/src/kilocode/session/revert.ts | 34 ++++++ packages/opencode/src/session/revert.ts | 12 +- packages/opencode/src/snapshot/index.ts | 29 ++++- .../test/kilocode/session/revert.test.ts | 112 +++++++++++++++++- 5 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 .changeset/atomic-session-revert.md create mode 100644 packages/opencode/src/kilocode/session/revert.ts diff --git a/.changeset/atomic-session-revert.md b/.changeset/atomic-session-revert.md new file mode 100644 index 0000000000..ed922d1a6e --- /dev/null +++ b/.changeset/atomic-session-revert.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored. diff --git a/packages/opencode/src/kilocode/session/revert.ts b/packages/opencode/src/kilocode/session/revert.ts new file mode 100644 index 0000000000..306d6af589 --- /dev/null +++ b/packages/opencode/src/kilocode/session/revert.ts @@ -0,0 +1,34 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { Effect } from "effect" +import { Instance } from "@/kilocode/instance" +import type { Snapshot } from "@/snapshot" + +export namespace KiloSessionRevert { + export const apply = Effect.fn("KiloSessionRevert.apply")(function* ( + snap: Snapshot.Interface, + patches: Snapshot.Patch[], + baseline?: string, + ) { + return yield* snap.revert(patches).pipe( + Effect.catchCause((cause) => { + if (!baseline) return Effect.failCause(cause) + return restore(snap, baseline).pipe(Effect.andThen(Effect.failCause(cause))) + }), + ) + }) + + export const restore = Effect.fn("KiloSessionRevert.restore")(function* (snap: Snapshot.Interface, hash: string) { + const current = yield* snap.track() + const removed = current + ? (yield* snap.diffFull(hash, current)).flatMap((file) => + file.status === "added" && file.file ? [path.resolve(Instance.directory, file.file)] : [], + ) + : [] + + yield* snap.restore(hash) + yield* Effect.forEach(removed, (file) => Effect.promise(() => fs.rm(file, { force: true, recursive: true })), { + discard: true, + }) + }) +} diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 4281cacca4..7062ffa39d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -10,6 +10,7 @@ import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" +import { KiloSessionRevert } from "@/kilocode/session/revert" // kilocode_change export const RevertInput = Schema.Struct({ sessionID: SessionID, @@ -81,14 +82,19 @@ export const layer = Layer.effect( : "unavailable" // kilocode_change end rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) - if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) + // kilocode_change start - do not mutate files without a durable compensation snapshot + if (patches.some((patch) => patch.files.length > 0) && !rev.snapshot) { + return yield* Effect.die(new Error("Cannot rewind files because the current workspace snapshot is unavailable")) + } + if (session.revert?.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot) + // kilocode_change end // kilocode_change start - compute diffs BEFORE reverting files so the diff // reflects changes being undone (files on disk still have AI modifications) const diffs = yield* summary.computeDiff({ messages: range }) // kilocode_change end - yield* snap.revert(patches) + yield* KiloSessionRevert.apply(snap, patches, rev.snapshot) // kilocode_change if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) @@ -118,7 +124,7 @@ export const layer = Layer.effect( yield* state.assertNotBusy(input.sessionID) const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (!session.revert) return session - if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot) + if (session.revert.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot) // kilocode_change yield* sessions.clearRevert(input.sessionID) return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 5b974dfd60..a1d7480978 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -208,7 +208,10 @@ export const layer: Layer.Layer = const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + // kilocode_change start - restoration must fail if deletion fails + const remove = (file: string) => + fs.remove(file, { force: true }).pipe(Effect.orDie) + // kilocode_change end // kilocode_change start - serialize snapshot repositories across CLI and extension processes const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(flock.withLock(fx, `snapshot:${state.gitdir}`).pipe(Effect.orDie)) @@ -471,13 +474,14 @@ export const layer: Layer.Layer = exitCode: checkout.code, stderr: checkout.stderr, }) - return + return yield* Effect.die(new Error(`Failed to restore snapshot ${snapshot}`)) // kilocode_change } yield* Effect.logError("failed to restore snapshot", { snapshot, exitCode: result.code, stderr: result.stderr, }) + return yield* Effect.die(new Error(`Failed to restore snapshot ${snapshot}`)) // kilocode_change }), ) }) @@ -485,6 +489,14 @@ export const layer: Layer.Layer = const revert = Effect.fnUntraced(function* (patches: Patch[]) { return yield* locked( Effect.gen(function* () { + // kilocode_change start - validate every checkpoint before mutating workspace files + for (const hash of new Set(patches.filter((item) => item.files.length > 0).map((item) => item.hash))) { + const tree = yield* git([...core, ...args(["cat-file", "-e", `${hash}^{tree}`])], { + cwd: state.worktree, + }) + if (tree.code !== 0) return yield* Effect.die(new Error(`Snapshot ${hash} is unavailable`)) + } + // kilocode_change end const ops: { hash: string; file: string; rel: string }[] = [] const seen = new Set() for (const item of patches) { @@ -508,13 +520,20 @@ export const layer: Layer.Layer = const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { cwd: state.worktree, }) - if (tree.code === 0 && tree.text.trim()) { - yield* Effect.logInfo("file existed in snapshot but checkout failed, keeping", { + // kilocode_change start - never report success for a file that Git could not restore + if (tree.code !== 0) { + return yield* Effect.die(new Error(`Snapshot ${op.hash} is unavailable`)) + } + if (tree.text.trim()) { + yield* Effect.logError("file existed in snapshot but checkout failed", { file: op.file, hash: op.hash, + exitCode: result.code, + stderr: result.stderr, }) - return + return yield* Effect.die(new Error(`Failed to restore ${op.file} from snapshot ${op.hash}`)) } + // kilocode_change end yield* Effect.logInfo("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash, diff --git a/packages/opencode/test/kilocode/session/revert.test.ts b/packages/opencode/test/kilocode/session/revert.test.ts index 0299328419..c0928211e9 100644 --- a/packages/opencode/test/kilocode/session/revert.test.ts +++ b/packages/opencode/test/kilocode/session/revert.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Layer } from "effect" import fs from "node:fs/promises" import path from "node:path" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -242,4 +242,114 @@ describe("workspace revert status", () => { { git: true }, ), ) + + it.live( + "keeps the conversation and workspace unchanged when a checkpoint cannot be fully restored", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + const snapshot = yield* Snapshot.Service + const session = yield* sessions.create({}) + const locked = path.join(dir, "locked") + const protectedFile = path.join(locked, "protected.txt") + const writableFile = path.join(dir, "writable.txt") + const providerID = ProviderV2.ID.make("test") + yield* Effect.promise(() => fs.mkdir(locked)) + yield* Effect.promise(() => fs.writeFile(protectedFile, "before")) + yield* Effect.promise(() => fs.writeFile(writableFile, "before")) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "user", + agent: "default", + model: { providerID, modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "change both files", + }) + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "assistant", + parentID: user.id, + mode: "default", + agent: "default", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID, + time: { created: Date.now() }, + finish: "end_turn", + }) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + yield* Effect.promise(() => fs.writeFile(protectedFile, "after")) + yield* Effect.promise(() => fs.writeFile(writableFile, "after")) + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + const patch = yield* snapshot.patch(before) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-start", + snapshot: before, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + + yield* Effect.promise(() => fs.chmod(protectedFile, 0o444)) + yield* Effect.promise(() => fs.chmod(locked, 0o555)) + const outcome = yield* revert.revert({ sessionID: session.id, messageID: user.id }).pipe( + Effect.exit, + Effect.ensuring( + Effect.promise(async () => { + await fs.chmod(locked, 0o755) + await fs.chmod(protectedFile, 0o644) + }), + ), + ) + const current = yield* sessions.get(session.id) + const actual = { + failed: Exit.isFailure(outcome), + reverted: current.revert !== undefined, + protected: yield* Effect.promise(() => fs.readFile(protectedFile, "utf8")), + writable: yield* Effect.promise(() => fs.readFile(writableFile, "utf8")), + } + + expect(actual).toEqual({ + failed: true, + reverted: false, + protected: "after", + writable: "after", + }) + }), + { git: true }, + ), + 30_000, + ) }) From 2c61d29fb7378482469296321030ab111344daa0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 11:09:55 +0200 Subject: [PATCH 2/6] fix(cli): compensate checkpoint restore failures --- .../opencode/src/kilocode/session/revert.ts | 61 ++-- packages/opencode/src/session/revert.ts | 86 ++++-- .../test/kilocode/session/revert.test.ts | 278 ++++++++++++------ 3 files changed, 287 insertions(+), 138 deletions(-) diff --git a/packages/opencode/src/kilocode/session/revert.ts b/packages/opencode/src/kilocode/session/revert.ts index 306d6af589..64ba8f98a0 100644 --- a/packages/opencode/src/kilocode/session/revert.ts +++ b/packages/opencode/src/kilocode/session/revert.ts @@ -1,34 +1,51 @@ -import fs from "node:fs/promises" -import path from "node:path" -import { Effect } from "effect" -import { Instance } from "@/kilocode/instance" +import { Cause, Effect } from "effect" +import type { MessageV2 } from "@/session/message-v2" +import type { Session } from "@/session/session" import type { Snapshot } from "@/snapshot" export namespace KiloSessionRevert { - export const apply = Effect.fn("KiloSessionRevert.apply")(function* ( + const rollback = (snap: Snapshot.Interface, hash: string, files: string[], cause: Cause.Cause) => + restore(snap, hash, files).pipe( + Effect.matchCauseEffect({ + onFailure: (next) => Effect.failCause(Cause.combine(cause, next)), + onSuccess: () => Effect.failCause(cause), + }), + ) + + export function files(messages: MessageV2.WithParts[], rev: NonNullable) { + const result: string[] = [] + let active = false + for (const msg of messages) { + for (const part of msg.parts) { + if (active && part.type === "patch") result.push(...part.files) + if (active || msg.info.id !== rev.messageID) continue + if (rev.partID && part.id !== rev.partID) continue + active = true + } + } + return [...new Set(result)] + } + + export const apply = Effect.fn("KiloSessionRevert.apply")(function* ( snap: Snapshot.Interface, - patches: Snapshot.Patch[], - baseline?: string, + baseline: string | undefined, + files: string[], + effect: Effect.Effect, ) { - return yield* snap.revert(patches).pipe( + return yield* effect.pipe( Effect.catchCause((cause) => { - if (!baseline) return Effect.failCause(cause) - return restore(snap, baseline).pipe(Effect.andThen(Effect.failCause(cause))) + if (!baseline || files.length === 0) return Effect.failCause(cause) + return rollback(snap, baseline, files, cause) }), ) }) - export const restore = Effect.fn("KiloSessionRevert.restore")(function* (snap: Snapshot.Interface, hash: string) { - const current = yield* snap.track() - const removed = current - ? (yield* snap.diffFull(hash, current)).flatMap((file) => - file.status === "added" && file.file ? [path.resolve(Instance.directory, file.file)] : [], - ) - : [] - - yield* snap.restore(hash) - yield* Effect.forEach(removed, (file) => Effect.promise(() => fs.rm(file, { force: true, recursive: true })), { - discard: true, - }) + export const restore = Effect.fn("KiloSessionRevert.restore")(function* ( + snap: Snapshot.Interface, + hash: string, + files: string[], + ) { + if (files.length === 0) return + yield* snap.revert([{ hash, files }]) }) } diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 7062ffa39d..a2d6801175 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -82,40 +82,45 @@ export const layer = Layer.effect( : "unavailable" // kilocode_change end rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) - // kilocode_change start - do not mutate files without a durable compensation snapshot - if (patches.some((patch) => patch.files.length > 0) && !rev.snapshot) { + // kilocode_change start - keep the entire workspace transition atomic + const prior = session.revert ? KiloSessionRevert.files(all, session.revert) : [] + const files = [...new Set([...prior, ...patches.flatMap((patch) => patch.files)])] + const baseline = session.revert?.snapshot && files.length > 0 ? yield* snap.track() : rev.snapshot + if (files.length > 0 && !baseline) { return yield* Effect.die(new Error("Cannot rewind files because the current workspace snapshot is unavailable")) } - if (session.revert?.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot) - // kilocode_change end + yield* KiloSessionRevert.apply( + snap, + baseline, + files, + Effect.gen(function* () { + if (session.revert?.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot, prior) - // kilocode_change start - compute diffs BEFORE reverting files so the diff - // reflects changes being undone (files on disk still have AI modifications) - const diffs = yield* summary.computeDiff({ messages: range }) + // Compute the user-facing diff while files still contain the changes being undone. + const diffs = yield* summary.computeDiff({ messages: range }) + yield* snap.revert(patches) + if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) + yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + const summaryDiffs: Snapshot.SummaryFileDiff[] = diffs.map((d) => ({ + file: d.file, + additions: d.additions, + deletions: d.deletions, + status: d.status, + })) + yield* sessions.setRevert({ + sessionID: input.sessionID, + revert: rev, + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, + diffs: summaryDiffs, + }, + }) + }), + ) // kilocode_change end - - yield* KiloSessionRevert.apply(snap, patches, rev.snapshot) // kilocode_change - if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) - yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) - // kilocode_change start - const summaryDiffs: Snapshot.SummaryFileDiff[] = diffs.map((d) => ({ - file: d.file, - additions: d.additions, - deletions: d.deletions, - status: d.status, - })) - // kilocode_change end - yield* sessions.setRevert({ - sessionID: input.sessionID, - revert: rev, - summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), - files: diffs.length, - diffs: summaryDiffs, // kilocode_change - }, - }) return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) @@ -124,8 +129,25 @@ export const layer = Layer.effect( yield* state.assertNotBusy(input.sessionID) const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (!session.revert) return session - if (session.revert.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot) // kilocode_change - yield* sessions.clearRevert(input.sessionID) + // kilocode_change start - preserve the reverted workspace if redo cannot complete + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const files = KiloSessionRevert.files(all, session.revert) + const baseline = files.length > 0 ? yield* snap.track() : undefined + if (files.length > 0 && !baseline) { + return yield* Effect.die( + new Error("Cannot restore files because the current workspace snapshot is unavailable"), + ) + } + yield* KiloSessionRevert.apply( + snap, + baseline, + files, + Effect.gen(function* () { + if (session.revert?.snapshot) yield* KiloSessionRevert.restore(snap, session.revert.snapshot, files) + yield* sessions.clearRevert(input.sessionID) + }), + ) + // kilocode_change end return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) diff --git a/packages/opencode/test/kilocode/session/revert.test.ts b/packages/opencode/test/kilocode/session/revert.test.ts index c0928211e9..a2274bb55c 100644 --- a/packages/opencode/test/kilocode/session/revert.test.ts +++ b/packages/opencode/test/kilocode/session/revert.test.ts @@ -6,11 +6,12 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { MessageV2 } from "@/session/message-v2" +import { KiloSessionRevert } from "@/kilocode/session/revert" import { SessionRevert } from "@/session/revert" import { MessageID, PartID } from "@/session/schema" import { Session } from "@/session/session" import { Snapshot } from "@/snapshot" -import { provideTmpdirInstance } from "../../fixture/fixture" +import { provideInstance, provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const env = Layer.mergeAll( @@ -20,6 +21,96 @@ const env = Layer.mergeAll( CrossSpawnSpawner.defaultLayer, ) const it = testEffect(env) +const guarded = process.platform === "win32" ? it.live.skip : it.live + +const setup = Effect.fnUntraced(function* (dir: string, deleted = false) { + const sessions = yield* Session.Service + const revert = yield* SessionRevert.Service + const snapshot = yield* Snapshot.Service + const session = yield* sessions.create({}) + const locked = path.join(dir, "locked") + const protectedFile = path.join(locked, "protected.txt") + const writableFile = path.join(dir, "writable.txt") + const providerID = ProviderV2.ID.make("test") + yield* Effect.promise(() => fs.mkdir(locked)) + yield* Effect.promise(() => fs.writeFile(protectedFile, "before")) + yield* Effect.promise(() => fs.writeFile(writableFile, "before")) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "user", + agent: "default", + model: { providerID, modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "change both files", + }) + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "assistant", + parentID: user.id, + mode: "default", + agent: "default", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID, + time: { created: Date.now() }, + finish: "end_turn", + }) + const before = yield* snapshot.track() + if (!before) throw new Error("expected snapshot") + if (deleted) yield* Effect.promise(() => fs.rm(protectedFile)) + if (!deleted) yield* Effect.promise(() => fs.writeFile(protectedFile, "after")) + yield* Effect.promise(() => fs.writeFile(writableFile, "after")) + const after = yield* snapshot.track() + if (!after) throw new Error("expected snapshot") + const patch = yield* snapshot.patch(before) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-start", + snapshot: before, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-finish", + reason: "stop", + snapshot: after, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + return { + sessions, + revert, + snapshot, + session, + user, + after, + patch, + locked, + protected: protectedFile, + writable: writableFile, + } +}) describe("partial assistant revert", () => { it.live( @@ -243,102 +334,29 @@ describe("workspace revert status", () => { ), ) - it.live( + guarded( "keeps the conversation and workspace unchanged when a checkpoint cannot be fully restored", provideTmpdirInstance( (dir) => Effect.gen(function* () { - const sessions = yield* Session.Service - const revert = yield* SessionRevert.Service - const snapshot = yield* Snapshot.Service - const session = yield* sessions.create({}) - const locked = path.join(dir, "locked") - const protectedFile = path.join(locked, "protected.txt") - const writableFile = path.join(dir, "writable.txt") - const providerID = ProviderV2.ID.make("test") - yield* Effect.promise(() => fs.mkdir(locked)) - yield* Effect.promise(() => fs.writeFile(protectedFile, "before")) - yield* Effect.promise(() => fs.writeFile(writableFile, "before")) - const user = yield* sessions.updateMessage({ - id: MessageID.ascending(), - sessionID: session.id, - role: "user", - agent: "default", - model: { providerID, modelID: ModelV2.ID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: user.id, - sessionID: session.id, - type: "text", - text: "change both files", - }) - const assistant = yield* sessions.updateMessage({ - id: MessageID.ascending(), - sessionID: session.id, - role: "assistant", - parentID: user.id, - mode: "default", - agent: "default", - path: { cwd: dir, root: dir }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ModelV2.ID.make("test"), - providerID, - time: { created: Date.now() }, - finish: "end_turn", - }) - const before = yield* snapshot.track() - if (!before) throw new Error("expected snapshot") - yield* Effect.promise(() => fs.writeFile(protectedFile, "after")) - yield* Effect.promise(() => fs.writeFile(writableFile, "after")) - const after = yield* snapshot.track() - if (!after) throw new Error("expected snapshot") - const patch = yield* snapshot.patch(before) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: assistant.id, - sessionID: session.id, - type: "step-start", - snapshot: before, - }) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: assistant.id, - sessionID: session.id, - type: "step-finish", - reason: "stop", - snapshot: after, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: assistant.id, - sessionID: session.id, - type: "patch", - hash: patch.hash, - files: patch.files, - }) - - yield* Effect.promise(() => fs.chmod(protectedFile, 0o444)) - yield* Effect.promise(() => fs.chmod(locked, 0o555)) - const outcome = yield* revert.revert({ sessionID: session.id, messageID: user.id }).pipe( + const item = yield* setup(dir) + yield* Effect.promise(() => fs.chmod(item.protected, 0o444)) + yield* Effect.promise(() => fs.chmod(item.locked, 0o555)) + const outcome = yield* item.revert.revert({ sessionID: item.session.id, messageID: item.user.id }).pipe( Effect.exit, Effect.ensuring( Effect.promise(async () => { - await fs.chmod(locked, 0o755) - await fs.chmod(protectedFile, 0o644) + await fs.chmod(item.locked, 0o755) + await fs.chmod(item.protected, 0o644) }), ), ) - const current = yield* sessions.get(session.id) + const current = yield* item.sessions.get(item.session.id) const actual = { failed: Exit.isFailure(outcome), reverted: current.revert !== undefined, - protected: yield* Effect.promise(() => fs.readFile(protectedFile, "utf8")), - writable: yield* Effect.promise(() => fs.readFile(writableFile, "utf8")), + protected: yield* Effect.promise(() => fs.readFile(item.protected, "utf8")), + writable: yield* Effect.promise(() => fs.readFile(item.writable, "utf8")), } expect(actual).toEqual({ @@ -352,4 +370,96 @@ describe("workspace revert status", () => { ), 30_000, ) + + guarded( + "keeps the reverted state when unrevert cannot fully restore files", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const item = yield* setup(dir) + yield* item.revert.revert({ sessionID: item.session.id, messageID: item.user.id }) + yield* Effect.promise(() => fs.chmod(item.protected, 0o444)) + yield* Effect.promise(() => fs.chmod(item.locked, 0o555)) + const outcome = yield* item.revert.unrevert({ sessionID: item.session.id }).pipe( + Effect.exit, + Effect.ensuring( + Effect.promise(async () => { + await fs.chmod(item.locked, 0o755) + await fs.chmod(item.protected, 0o644) + }), + ), + ) + const current = yield* item.sessions.get(item.session.id) + + expect({ + failed: Exit.isFailure(outcome), + reverted: current.revert !== undefined, + protected: yield* Effect.promise(() => fs.readFile(item.protected, "utf8")), + writable: yield* Effect.promise(() => fs.readFile(item.writable, "utf8")), + }).toEqual({ failed: true, reverted: true, protected: "before", writable: "before" }) + }), + { git: true }, + ), + 30_000, + ) + + guarded( + "keeps the prior revert when replacing its checkpoint cannot restore files", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const item = yield* setup(dir) + yield* item.revert.revert({ sessionID: item.session.id, messageID: item.user.id }) + yield* Effect.promise(() => fs.chmod(item.protected, 0o444)) + yield* Effect.promise(() => fs.chmod(item.locked, 0o555)) + const outcome = yield* item.revert.revert({ sessionID: item.session.id, messageID: item.user.id }).pipe( + Effect.exit, + Effect.ensuring( + Effect.promise(async () => { + await fs.chmod(item.locked, 0o755) + await fs.chmod(item.protected, 0o644) + }), + ), + ) + const current = yield* item.sessions.get(item.session.id) + + expect({ + failed: Exit.isFailure(outcome), + reverted: current.revert !== undefined, + protected: yield* Effect.promise(() => fs.readFile(item.protected, "utf8")), + writable: yield* Effect.promise(() => fs.readFile(item.writable, "utf8")), + }).toEqual({ failed: true, reverted: true, protected: "before", writable: "before" }) + }), + { git: true }, + ), + 30_000, + ) + + it.live( + "unreverts deleted files from a session rooted in a worktree subdirectory", + provideTmpdirInstance( + (root) => + Effect.gen(function* () { + const dir = path.join(root, "nested") + yield* Effect.promise(() => fs.mkdir(dir)) + const item = yield* setup(dir, true) + yield* item.revert.revert({ sessionID: item.session.id, messageID: item.user.id }) + expect(yield* Effect.promise(() => fs.readFile(item.protected, "utf8"))).toBe("before") + + yield* KiloSessionRevert.restore(item.snapshot, item.after, item.patch.files).pipe(provideInstance(dir)) + + expect( + yield* Effect.promise(() => + fs.stat(item.protected).then( + () => true, + () => false, + ), + ), + ).toBe(false) + expect(yield* Effect.promise(() => fs.readFile(item.writable, "utf8"))).toBe("after") + }), + { git: true }, + ), + 30_000, + ) }) From d41e0cdc0448c0ae8106bd24e0c89fcc3433af92 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 28 Jul 2026 09:18:06 +0000 Subject: [PATCH 3/6] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index c10bd672c3..8f0a833b46 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UHxMHmx17Jex0yXgbpXCvIORubs9cFMsIXGacvs9+gA=", - "aarch64-linux": "sha256-tTnW84VaNEbfo46H24ETKZgFumZMwu6pgNDlY8KYkqo=", - "aarch64-darwin": "sha256-tBi4D1tfACA4ogYMdyjUK8sDb370rAGE2q8baeJjpdA=", - "x86_64-darwin": "sha256-MPIai9M+EQps81qp7RBmTsW/qPjcrWd7GGJQyeNCz/U=" + "x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=", + "aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=", + "aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=", + "x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020=" } } From 160b06661acc5f04b21221ab6578c468325f64c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 12:48:30 +0200 Subject: [PATCH 4/6] fix(vscode): avoid eager worktree watchers --- .changeset/quiet-vscode-watchers.md | 6 ++++++ packages/opencode/src/kilocode/watcher.ts | 8 ++++++-- .../test/kilocode/instance-vcs-watcher.test.ts | 14 +++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-vscode-watchers.md diff --git a/.changeset/quiet-vscode-watchers.md b/.changeset/quiet-vscode-watchers.md new file mode 100644 index 0000000000..0f44b7cfda --- /dev/null +++ b/.changeset/quiet-vscode-watchers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree. diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index b953762e59..533e854b12 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -15,6 +15,10 @@ export namespace KilocodeWatcher { export class Service extends Context.Service()("@kilocode/Watcher") {} + export function eager(client = process.env["KILO_CLIENT"]) { + return client !== "vscode" + } + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -51,10 +55,10 @@ export namespace KilocodeWatcher { }), ) - // Gate the whole layer so LocationServiceMap's dependency graph is never built when the watcher is disabled. + // Gate the whole layer so LocationServiceMap is only warmed for clients that consume branch-update events. export const defaultLayer = Layer.unwrap( Effect.gen(function* () { - if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false))) + if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false)))) return Layer.succeed(Service, Service.of({ init: () => Effect.void })) return layer.pipe(Layer.provide(LocationServiceMap.layer)) }), diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index 3bd83f86fe..995555f368 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -1,10 +1,11 @@ -import { afterAll, beforeAll, expect } from "bun:test" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" import { InstanceLayer } from "../../src/project/instance-layer" import { InstanceStore } from "../../src/project/instance-store" +import { KilocodeWatcher } from "../../src/kilocode/watcher" import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" @@ -23,6 +24,17 @@ afterAll(() => { // The watcher is unreliable on Windows CI, so this test only runs on unix. const live = process.platform === "win32" ? it.live.skip : it.live +describe("KilocodeWatcher.eager", () => { + test("skips eager location watchers for VS Code", () => { + expect(KilocodeWatcher.eager("vscode")).toBe(false) + }) + + test("keeps eager location watchers for the standalone CLI", () => { + expect(KilocodeWatcher.eager("cli")).toBe(true) + expect(KilocodeWatcher.eager(undefined)).toBe(true) + }) +}) + live("instances publish branch updates after git switch", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 8f4b14610398c5f89d671d4c602115df529e095b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 28 Jul 2026 12:55:41 +0200 Subject: [PATCH 5/6] fix(vscode): use normalized client flag --- packages/opencode/src/kilocode/watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index 533e854b12..1e7f70f39f 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -15,7 +15,7 @@ export namespace KilocodeWatcher { export class Service extends Context.Service()("@kilocode/Watcher") {} - export function eager(client = process.env["KILO_CLIENT"]) { + export function eager(client = Flag.KILO_CLIENT) { return client !== "vscode" } From 8c880487818728f41ffc3087d27d6ce6b4591b53 Mon Sep 17 00:00:00 2001 From: Whitebeard Date: Tue, 28 Jul 2026 17:05:59 +0530 Subject: [PATCH 6/6] fix(nix): use the required Bun version for builds (#12592) --- .changeset/fix-nix-bun-pin.md | 5 +++ flake.nix | 59 +++------------------------------- nix/bun.nix | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 .changeset/fix-nix-bun-pin.md create mode 100644 nix/bun.nix diff --git a/.changeset/fix-nix-bun-pin.md b/.changeset/fix-nix-bun-pin.md new file mode 100644 index 0000000000..5facef0667 --- /dev/null +++ b/.changeset/fix-nix-bun-pin.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Nix builds on the Bun version required by the repository. diff --git a/flake.nix b/flake.nix index 7383da725d..280d60035c 100644 --- a/flake.nix +++ b/flake.nix @@ -21,59 +21,7 @@ devShells = forEachSystem (pkgs: { default = let - # Pin bun to the version declared in package.json (packageManager: "bun@1.3.14"). - # The locked nixpkgs revision ships 1.3.11, so we fetch the official release directly. - bun = - let - sources = { - "aarch64-linux" = { - name = "bun-linux-aarch64"; - hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; - }; - "x86_64-linux" = { - name = "bun-linux-x64"; - hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; - }; - "aarch64-darwin" = { - name = "bun-darwin-aarch64"; - hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; - }; - "x86_64-darwin" = { - name = "bun-darwin-x64"; - hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; - }; - }; - source = - sources.${pkgs.stdenv.hostPlatform.system} - or (throw "Unsupported system for bun: ${pkgs.stdenv.hostPlatform.system}"); - in - pkgs.stdenv.mkDerivation rec { - pname = "bun"; - version = "1.3.14"; - src = pkgs.fetchurl { - url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip"; - inherit (source) hash; - }; - nativeBuildInputs = [ - pkgs.unzip - ] ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.autoPatchelfHook; - buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ]; - dontConfigure = true; - dontBuild = true; - installPhase = '' - runHook preInstall - install -Dm755 bun $out/bin/bun - ln -s $out/bin/bun $out/bin/bunx - runHook postInstall - ''; - meta = { - description = "Fast all-in-one JavaScript runtime"; - homepage = "https://bun.sh"; - license = pkgs.lib.licenses.mit; - mainProgram = "bun"; - platforms = builtins.attrNames sources; - }; - }; + bun = pkgs.callPackage ./nix/bun.nix { }; kilo-dev = pkgs.writeShellScriptBin "kilo-dev" '' set -euo pipefail @@ -279,11 +227,12 @@ packages = forEachSystem ( pkgs: let + bun = pkgs.callPackage ./nix/bun.nix { }; node_modules = pkgs.callPackage ./nix/node_modules.nix { - inherit rev; + inherit bun rev; }; kilo = pkgs.callPackage ./nix/kilo.nix { - inherit node_modules; + inherit bun node_modules; }; in { diff --git a/nix/bun.nix b/nix/bun.nix new file mode 100644 index 0000000000..a880895349 --- /dev/null +++ b/nix/bun.nix @@ -0,0 +1,60 @@ +{ + lib, + stdenv, + fetchurl, + unzip, + autoPatchelfHook, +}: +let + package = lib.pipe ../package.json [ + builtins.readFile + builtins.fromJSON + ]; + version = lib.removePrefix "bun@" package.packageManager; + sources = { + "aarch64-linux" = { + name = "bun-linux-aarch64"; + hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; + }; + "x86_64-linux" = { + name = "bun-linux-x64"; + hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; + }; + "aarch64-darwin" = { + name = "bun-darwin-aarch64"; + hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; + }; + "x86_64-darwin" = { + name = "bun-darwin-x64"; + hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; + }; + }; + source = + sources.${stdenv.hostPlatform.system} + or (throw "Unsupported system for bun: ${stdenv.hostPlatform.system}"); +in +stdenv.mkDerivation { + pname = "bun"; + inherit version; + src = fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip"; + inherit (source) hash; + }; + nativeBuildInputs = [ unzip ] ++ lib.optional stdenv.isLinux autoPatchelfHook; + buildInputs = lib.optionals stdenv.isLinux [ stdenv.cc.cc.lib ]; + dontConfigure = true; + dontBuild = true; + installPhase = '' + runHook preInstall + install -Dm755 bun $out/bin/bun + ln -s $out/bin/bun $out/bin/bunx + runHook postInstall + ''; + meta = { + description = "Fast all-in-one JavaScript runtime"; + homepage = "https://bun.sh"; + license = lib.licenses.mit; + mainProgram = "bun"; + platforms = builtins.attrNames sources; + }; +}