Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ef663e2
docs(core): plan the SQLite snapshot-loss fix for #162
brentyates-swx Oct 10, 2026
e6461df
fix(core): restore the SQLite lock-wait window for busy statements
brentyates-swx Oct 10, 2026
1948f9d
fix(opencode): admit queue writes under BEGIN IMMEDIATE
brentyates-swx Oct 10, 2026
ac9eadd
Merge origin/swxtch into fm/opencode-lock-fix-162
brentyates-swx Oct 10, 2026
9e088df
refactor(core): give the SQLite busy-retry gate one owner
brentyates-swx Oct 10, 2026
381667f
test(opencode): wait on the admission's busy attempt, not a sleep
brentyates-swx Oct 10, 2026
d71e35e
test(opencode): read the admission probe's query method through its d…
brentyates-swx Oct 10, 2026
53c96a3
docs(opencode): require queue writes to go through writable()
brentyates-swx Oct 10, 2026
8d2e350
test(core): one statement-attempt counter for the busy tests
brentyates-swx Oct 10, 2026
7b0ea4a
docs: record the #162 lock fix in CHANGESET.md
brentyates-swx Oct 10, 2026
33237dd
docs(core): plan SQLite lock-hold and stop-site diagnostics (#180)
brentyates-swx Oct 10, 2026
db87d48
feat(core): log long SQLite write-lock holds and lock retry give-ups
brentyates-swx Oct 10, 2026
cebd89f
Merge origin/swxtch at b18e7ff543 into fm/opencode-lock-fix-162
brentyates-swx Oct 10, 2026
3010fba
test(opencode): prove halt names the failed write's event, case and c…
brentyates-swx Oct 10, 2026
3e8dbb5
fix(core): omit an absent span from the lock-hold line
brentyates-swx Oct 10, 2026
f51188c
Merge origin/fm/opencode-lock-fix-162 at cebd89f943 (swxtch b18e7ff54…
brentyates-swx Oct 10, 2026
7312e44
docs: record the stop-instrument diagnostics in CHANGESET.md
brentyates-swx Oct 10, 2026
46cc805
Merge origin/swxtch at dcf2c1a5c9 (#194 landed) into fm/opencode-stop…
brentyates-swx Oct 10, 2026
1cdb1bd
docs: say the stop-instrument entry follows #194, which has landed
brentyates-swx Oct 10, 2026
261a931
fix(core): name an exhausted statement by its operation only
brentyates-swx Oct 10, 2026
4bb7f87
fix(core): take the stop call site from where the failure was raised
brentyates-swx Oct 10, 2026
b8c1bba
docs: record the stop-instrument entry as #201 in CHANGESET.md
brentyates-swx Oct 10, 2026
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ Newest first. Each entry names the upstream range brought in.

### Runtime fixes

- **#201** Log SQLite write-lock holds and lock-stop call sites, diagnostics only (firstmate task
opencode-stop-instrument-180, follows #194): an immediate transaction holding the write lock over 250 ms logs its
pid, purpose and duration; a lock failure leaving the retry gate logs one summary with the statement head, attempts,
elapsed time and native code; and halt's error line names the failed write's event, stream case and call site. No
behaviour changes. _Fork-only._
- **#194** Fix dropped accepted prompts under concurrent SQLite writers (swxtchio/swx-opencode#162, #165): queue writes begin with BEGIN IMMEDIATE, so a write waits on the lock instead of failing on a snapshot pinned by an earlier read, and the busy-retry gate has one owner in effect-sqlite-node/src/retry.ts. Queue writes on the consumption path use the same lock, which is beyond #167's scope. _Fork-only._
- **#163** Revert #155 (swxtchio/swx-opencode#153): under concurrent writers it dropped more accepted prompts than its parent #150, so the busy waits return to the pre-#155 schedule. The fix-forward is tracked in swxtchio/swx-opencode#162. _Fork-only._
- **#144** Make `opencode db path` print the selected path without initializing `AppRuntime` or SQLite, defer global directory creation to runtime initialization, and preserve explicit and channel-specific database path selection. _Upstreamable._
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/database/sqlite.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const make = (options: Config) =>
try: () => (statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>,
catch: statementError,
})
return retryLocked(execute)
return retryLocked(execute, query)
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Expand All @@ -95,7 +95,7 @@ const make = (options: Config) =>
try: () => (statement.values(...(params as any)) ?? []) as Array<unknown[]>,
catch: statementError,
})
return retryLocked(execute)
return retryLocked(execute, query)
})

const connection = identity<SqliteConnection>({
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/database/sqlite.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const make = (options: Config) =>
try: () => statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>,
catch: statementError,
})
return retryLocked(execute)
return retryLocked(execute, query)
})

const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Expand All @@ -103,7 +103,7 @@ const make = (options: Config) =>
try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray<ReadonlyArray<unknown>>,
catch: statementError,
})
return retryLocked(execute)
return retryLocked(execute, query)
})

const connection = identity<SqliteConnection>({
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt, inArray } from "drizzle-orm"
import { Database } from "./database/database"
import { TransactionPurpose } from "@opencode-ai/effect-drizzle-sqlite"
import { FailureOrigin } from "./observability/failure-origin"
import { EventRetentionTable, EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { makeGlobalNode } from "./effect/app-node"
Expand Down Expand Up @@ -466,7 +468,11 @@ export const layerWith = (options?: LayerOptions) =>
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
.pipe(
Effect.provideService(TransactionPurpose, definition.type),
Effect.tapCause((cause) => FailureOrigin.record(cause, { event: definition.type })),
Effect.orDie,
)
if (committed) {
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/observability/failure-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Cause, Context, Effect } from "effect"

// Where a failure came from, for diagnostics only. Keyed by the failure object, so the attribution follows that
// failure through Cause squashing and later work cannot overwrite it; the failure itself is never changed.
export type Origin = {
readonly event?: string
readonly stage?: "handler" | "stream"
readonly case?: string
readonly callSite?: string
}

const origins = new WeakMap<object, Origin>()

// The innermost recording wins each field. The call site is the stack frame chain the runtime captured where the
// failure was raised, so it names the failing callable even when it is recorded after those scopes have unwound or
// across a stream boundary.
export const record = (cause: Cause.Cause<unknown>, origin: Omit<Origin, "callSite">) =>
Effect.sync(() => {
if (Cause.hasInterruptsOnly(cause)) return
const failure = Cause.squash(cause)
if (typeof failure !== "object" || failure === null) return
const callSite = frameChain(cause)
origins.set(failure, { ...origin, ...(callSite && { callSite }), ...origins.get(failure) })
})

export const get = (failure: unknown) =>
typeof failure === "object" && failure !== null ? origins.get(failure) : undefined

function frameChain(cause: Cause.Cause<unknown>) {
const names: string[] = []
const captured = Context.getOption(Cause.annotations(cause), Cause.StackTrace)
let frame = captured._tag === "Some" ? captured.value : undefined
while (frame && names.length < 8) {
// Effect.fn records a definition-site frame beside each call frame; the call frames name the path.
if (!frame.name.endsWith(" (definition)")) names.push(frame.name)
frame = frame.parent
}
return names.length ? names.join(" < ") : undefined
}

export * as FailureOrigin from "./failure-origin"
Loading
Loading