Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skip-disabled-snapshot-locks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Prevent interrupted snapshot progress from poisoning future prompts, and skip snapshot lock waits when snapshots are disabled.
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/session/part-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MessageV2 } from "@/session/message-v2"
import type { MessageV2 } from "@/session/message-v2"

export namespace KiloPartLifecycle {
export const key = "kilocode.lifecycle"
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/kilocode/snapshot/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ export namespace KiloSnapshotTrack {
const timeoutMs = input.timeoutMs ?? TIMEOUT_MS
const progressDelayMs = input.progressDelayMs ?? PROGRESS_DELAY_MS
const cleanupTimeoutMs = input.progressCleanupTimeoutMs ?? PROGRESS_CLEANUP_TIMEOUT_MS
// Progress cleanup can outlive this fiber, but its events must retain the project directory.
const bridge = yield* EffectBridge.make()
const call = <A>(fn: () => Promise<A>) => bridge.promise(Effect.promise(fn))

// The progress part is only published when we have both a session and
// a target message. Background/non-turn callers skip the indicator.
Expand Down Expand Up @@ -321,8 +324,7 @@ export namespace KiloSnapshotTrack {
timeout.resolve(false)
}, cleanupTimeoutMs)
const removed = await Promise.race([
hooks
.endProgress({ handle }, ctl.signal)
call(() => hooks.endProgress({ handle }, ctl.signal))
.then(() => true as const)
.catch((err) => {
log.warn("failed to clear snapshot progress part", { err })
Expand Down Expand Up @@ -373,7 +375,7 @@ export namespace KiloSnapshotTrack {
handle.started = true
const started = yield* Effect.promise((signal) =>
settleProgress(
() => hooks.startProgress({ handle, text: nextFrameText() }, signal),
() => call(() => hooks.startProgress({ handle, text: nextFrameText() }, signal)),
"failed to publish snapshot progress part",
),
)
Expand All @@ -384,7 +386,7 @@ export namespace KiloSnapshotTrack {
const text = nextFrameText()
yield* Effect.promise((signal) =>
settleProgress(
() => hooks.updateProgress({ handle, text }, signal),
() => call(() => hooks.updateProgress({ handle, text }, signal)),
"failed to advance snapshot spinner frame",
),
)
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Snapshot } from "@/snapshot" // kilocode_change
import { SessionNetwork } from "./network" // kilocode_change
import { CodexAuthExpiredError } from "@/kilocode/provider/codex-refresh" // kilocode_change
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change
import { KiloPartLifecycle } from "@/kilocode/session/part-lifecycle" // kilocode_change
import * as TextStream from "@/kilocode/text-stream" // kilocode_change
import { BoardNotice } from "@/kilocode/board/notice" // kilocode_change
import { Effect, Schema } from "effect"
Expand Down Expand Up @@ -399,6 +400,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (KiloPartLifecycle.transient(part)) continue // kilocode_change - never replay transient UI parts
// kilocode_change - !part.ignored keeps local UI warnings out of future prompts
if (part.type === "text" && !part.ignored) {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const layer: Layer.Layer<Service, never, Requirements> =
// kilocode_change end

const cleanup = Effect.fnUntraced(function* () {
if ((yield* config.get()).snapshot === false) return undefined // kilocode_change - skip locks for disabled periodic cleanup too
return yield* locked(
Effect.gen(function* () {
if (!(yield* enabled())) return
Expand Down Expand Up @@ -930,6 +931,8 @@ export const layer: Layer.Layer<Service, never, Requirements> =
}),
// kilocode_change start - isolate turn-facing snapshot work from poisoned locks
track: Effect.fn("Snapshot.track")(function* (opts) {
// Check before starting progress or waiting on an earlier snapshot's lock.
if ((yield* config.get()).snapshot === false) return undefined
const ctx = yield* InstanceState.context
const guard = trackState(ctx.worktree)
return yield* KiloSnapshotTrack.protect({
Expand All @@ -946,6 +949,7 @@ export const layer: Layer.Layer<Service, never, Requirements> =
})
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
if ((yield* config.get()).snapshot === false) return { hash, files: [] }
const ctx = yield* InstanceState.context
const guard = trackState(ctx.worktree)
return yield* KiloSnapshotTrack.protect({
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/kilocode/message-v2-transient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageV2 } from "../../src/session/message-v2"
import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import type { Provider } from "../../src/provider/provider"

const sessionID = SessionID.make("ses_transient")
const providerID = ProviderV2.ID.make("test")
const model: Provider.Model = {
id: ModelV2.ID.make("test-model"),
providerID,
api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" },
} as Provider.Model

const base = (messageID: string, id: string) => ({
id: PartID.make(id),
sessionID,
messageID: MessageID.make(messageID),
})

test("does not replay persisted transient snapshot progress", async () => {
const userID = MessageID.make("msg_user")
const assistantID = MessageID.make("msg_assistant")
const input: SessionV1.WithParts[] = [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 0 },
agent: "user",
model: { providerID, modelID: model.id },
tools: {},
mode: "",
} as SessionV1.User,
parts: [{ ...base(userID, "prt_user"), type: "text", text: "continue" }],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
parentID: userID,
time: { created: 0 },
modelID: model.id,
providerID,
mode: "",
agent: "build",
path: { cwd: "/", root: "/" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
} as SessionV1.Assistant,
parts: [
{
...base(assistantID, "prt_tool"),
type: "tool",
tool: "bash",
callID: "call_bash",
state: {
status: "completed",
input: {},
output: "changed files",
title: "bash",
time: { start: 0, end: 1 },
},
},
{
...base(assistantID, "prt_progress"),
type: "text",
text: "Initializing snapshot…",
synthetic: true,
metadata: { [KiloPartLifecycle.key]: "transient" },
},
] as SessionV1.Part[],
},
]

const messages = await MessageV2.toModelMessages(input, model)
expect(JSON.stringify(messages)).not.toContain("Initializing snapshot")
expect(messages.some((message) => message.role === "tool")).toBe(true)
})
37 changes: 37 additions & 0 deletions packages/opencode/test/kilocode/snapshot-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Snapshot } from "../../src/snapshot"
import { requireInstance } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"

const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppNodeBuilder.build(EffectFlock.node)))

for (const operation of ["track", "patch", "cleanup"] as const) {
it.instance(
`disabled snapshots bypass a held repository lock during ${operation}`,
() =>
Effect.gen(function* () {
const ctx = yield* requireInstance
const snapshot = yield* Snapshot.Service
const flock = yield* EffectFlock.Service
const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree))
yield* flock.acquire(`snapshot:${gitdir}`)

const effect: Effect.Effect<unknown> =
operation === "patch" ? snapshot.patch("existing-snapshot") : snapshot[operation]()
const result = yield* awaitWithTimeout(
effect,
`${operation} waited for a snapshot lock while disabled`,
"1 second",
)

expect(result).toEqual(operation === "patch" ? { hash: "existing-snapshot", files: [] } : undefined)
}),
{ git: true, config: { snapshot: false } },
)
}
77 changes: 76 additions & 1 deletion packages/opencode/test/kilocode/snapshot-track-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
// touch the real Question module or write to the filesystem.

import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Deferred, Duration, Effect, Fiber } from "effect"
import * as TestClock from "effect/testing/TestClock"
import path from "path"
import { PartID, type MessageID, type SessionID } from "../../src/session/schema"
import { KiloSnapshotTrack } from "../../src/kilocode/snapshot/track"
import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle"
import { TestInstance } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Session } from "../../src/session/session"
import { requireInstance, TestInstance } from "../fixture/fixture"
import { awaitWithTimeout, it } from "../lib/effect"

const SESSION = "ses_test" as SessionID
Expand Down Expand Up @@ -951,6 +957,75 @@ describe("KiloSnapshotTrack progress indicator", () => {
})
})

describe("KiloSnapshotTrack default hooks", () => {
it.instance(
"preserves the instance directory for real session progress events",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* requireInstance
const session = yield* Effect.promise(() =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.create({ title: "snapshot progress" })).pipe(
Effect.provideService(InstanceRef, ctx),
),
),
)
const message = yield* Effect.promise(() =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.updateMessage({
id: MESSAGE,
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
}),
).pipe(Effect.provideService(InstanceRef, ctx)),
),
)

const seen: GlobalEvent[] = []
const removed = yield* Deferred.make<void>()
const on = (event: GlobalEvent) => {
const properties = event.payload?.properties
if (properties?.sessionID !== session.id && properties?.part?.sessionID !== session.id) return
seen.push(event)
if (event.payload?.type === "message.part.removed") Deferred.doneUnsafe(removed, Effect.succeed(undefined))
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
GlobalBus.off("event", on)
await AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(session.id)).pipe(Effect.provideService(InstanceRef, ctx)),
)
}),
)

const result = yield* KiloSnapshotTrack.wrap({
inner: slowInner(350, "progress-hash"),
state: KiloSnapshotTrack.makeState(),
sessionID: session.id,
messageID: message.id,
timeoutMs: 1_000,
progressDelayMs: 1,
})

expect(result).toBe("progress-hash")
yield* awaitWithTimeout(Deferred.await(removed), "timed out waiting for snapshot progress removal")
const progress = seen.filter(
(event) => event.payload?.type === "message.part.updated" || event.payload?.type === "message.part.removed",
)
expect(progress.some((event) => event.payload?.type === "message.part.updated")).toBe(true)
expect(progress.some((event) => event.payload?.type === "message.part.removed")).toBe(true)
for (const event of progress) expect(event.directory).toBe(test.directory)
}),
{ git: true },
)
})

describe("KiloSnapshotTrack persistDisable", () => {
it.instance(
"disable writes snapshot:false to the project config",
Expand Down
4 changes: 4 additions & 0 deletions script/check-opencode-promise-facades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const testAllow: Record<string, { count: number; reason: string }> = {
count: 2,
reason: "disk-backed instance integration test cleanup",
},
"kilocode/snapshot-track-timeout.test.ts": {
count: 4,
reason: "production default snapshot hooks require the shared runtime and instance context",
},
"kilocode/kilo-sessions.test.ts": {
count: 36,
reason:
Expand Down
Loading