fix(cli): keep session reverts atomic

This commit is contained in:
marius-kilocode
2026-07-28 10:34:40 +02:00
parent 40cbad4f36
commit 16f8e7ef7f
5 changed files with 184 additions and 9 deletions
@@ -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,
)
})