From c9199cb529fd24be5d7deaa2cffc853d251ebbca Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 5 Aug 2026 10:25:56 +0200 Subject: [PATCH] fix(cli): handle sqlite lock errors --- .changeset/quiet-sqlite-lock-errors.md | 5 ++++ .../src/kilocode/database/sqlite-error.ts | 7 +++++ .../instance/httpapi/handlers/session.ts | 13 ++++++-- .../instance/httpapi/middleware/error.ts | 14 +++++++++ .../kilocode/database/sqlite-error.test.ts | 30 +++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-sqlite-lock-errors.md create mode 100644 packages/opencode/src/kilocode/database/sqlite-error.ts create mode 100644 packages/opencode/test/kilocode/database/sqlite-error.test.ts diff --git a/.changeset/quiet-sqlite-lock-errors.md b/.changeset/quiet-sqlite-lock-errors.md new file mode 100644 index 00000000000..db7cb238f9d --- /dev/null +++ b/.changeset/quiet-sqlite-lock-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show a concise retryable message when concurrent Kilo processes temporarily lock the SQLite database instead of printing the full server error trace. diff --git a/packages/opencode/src/kilocode/database/sqlite-error.ts b/packages/opencode/src/kilocode/database/sqlite-error.ts new file mode 100644 index 00000000000..31e476a8005 --- /dev/null +++ b/packages/opencode/src/kilocode/database/sqlite-error.ts @@ -0,0 +1,7 @@ +import { isSqlError } from "effect/unstable/sql/SqlError" + +export const busyMessage = "Database is busy. Please try again in a moment." + +export function isBusy(error: unknown) { + return isSqlError(error) && error.reason._tag === "LockTimeoutError" +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index eba9b764589..a54c5454da7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,4 +1,5 @@ import { Image } from "@/image/image" // kilocode_change - classify user image validation defects +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change @@ -322,13 +323,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.void // kilocode_change - Stop is not an error return Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) const error = Cause.squash(cause) + // kilocode_change start - keep SQLite lock failures out of local CLI logs + const busy = isBusy(error) + if (busy) { + yield* Effect.logWarning("prompt_async database busy", { sessionID: ctx.params.sessionID }) + } + if (!busy) yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + // kilocode_change end yield* events.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: AgentRequirementError.isInstance(error) ? error.toObject() - : new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), + : busy // kilocode_change + ? new NamedError.Unknown({ message: busyMessage }).toObject() // kilocode_change + : new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), // kilocode_change }) }) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 21b53aba756..f1afbf91746 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -1,5 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error" +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" @@ -16,6 +17,19 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) if (!defect) return Effect.failCause(cause) const error = defect.defect + // kilocode_change start - SQLite lock contention is expected with multiple local clients + if (isBusy(error)) { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logWarning("database busy", { ref }).pipe( + Effect.as( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ message: busyMessage, ref }).toObject(), + { status: 503 }, + ), + ), + ) + } + // kilocode_change end if ( ConfigErrorV1.JsonError.isInstance(error) || ConfigErrorV1.InvalidError.isInstance(error) || diff --git a/packages/opencode/test/kilocode/database/sqlite-error.test.ts b/packages/opencode/test/kilocode/database/sqlite-error.test.ts new file mode 100644 index 00000000000..7a3ea7472e1 --- /dev/null +++ b/packages/opencode/test/kilocode/database/sqlite-error.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" + +describe("SQLite errors", () => { + test("recognizes lock timeouts as busy database errors", () => { + const error = new SqlError({ + reason: new LockTimeoutError({ + cause: new Error("database is locked"), + message: "Failed to execute statement", + operation: "execute", + }), + }) + + expect(isBusy(error)).toBe(true) + expect(busyMessage).toBe("Database is busy. Please try again in a moment.") + }) + + test("does not classify other SQLite errors as busy", () => { + const error = new SqlError({ + reason: new UnknownError({ + cause: new Error("constraint failed"), + message: "Failed to execute statement", + operation: "execute", + }), + }) + + expect(isBusy(error)).toBe(false) + }) +})