Skip to content

Commit 00695da

Browse files
committed
chore(release): reconcile native fix with dev
2 parents 93959af + 8060765 commit 00695da

22 files changed

Lines changed: 1394 additions & 59 deletions

.github/releases/v1.0.40.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,44 @@
99
- **Tools survive automatic compaction, #539**: a high-usage `step-finish` could abort a slow local tool before its result reached the session processor. Native LLM now delivers all local tool results before terminal events. Parallel tools settle completely; explicit user cancellation still interrupts execution.
1010
- **Summary diffs retain later small entries, #526**: skip an oversized diff individually instead of dropping every following entry. Remove the unused legacy `session.summary_diffs` column through a tested database migration.
1111
- **Identical durable events no longer consume storage or sequence numbers, #527**: suppress byte-identical fresh appends within the same aggregate/type while preserving explicit-sequence replay. Batch results retain input alignment; legacy rows require no hash backfill.
12+
- **Config startup preserves npm lock files, #542**: keep an existing lock unchanged when the plugin SDK resolves entirely from local or bundled packages. Mixed registry requests and genuine package changes still regenerate the lock.
13+
14+
---
15+
16+
### 🏗️ Architecture / Refactor
17+
18+
- **Deleted-session storage reclamation, #537**: remove durable event residue for deleted aggregates, wire cleanup into session deletion, and add tested SQLite reclamation support. This release does not run the deferred #531 maintenance operation on the user's existing database.
1219

1320
---
1421

1522
### ⚙️ CI / Engineering
1623

1724
- **Delivery tracking, #520 and #532 through #535**: close linked issues after dev merges, preserve repository-specific SpecGit harness files, restore failed bootstrap state, reject unsupported branch types before remote writes, and verify that delivery PRs target dev.
1825
- **macOS installation verification, #536**: verify release archive checksums before extraction and validate the installed binary's signature after quarantine clearing and re-signing. Added a negative checksum control and a real macOS installation acceptance test.
26+
- **Local npm fixture isolation, #540**: keep real package-installation regressions independent of online vulnerability-audit latency while retaining their assertions and deadlines.
1927

2028
---
2129

2230
### 🧪 Test Summary
2331

2432
```
25-
LLM / native runtime / processor targeted suites: 66 pass, 1 existing skip, 0 fail
26-
opencode typecheck: passed
33+
Integration CI baseline (dev 8060765fcc):
34+
core: 1225 pass, 6 skip, 0 fail
35+
opencode: 4426 pass, 23 skip, 1 todo, 0 fail
36+
HttpAPI coverage / auth / effect: 230 pass each, no failures or missing routes
37+
Generated client and SDK freshness: passed
38+
Typecheck, DAG core gate, Linux and Windows E2E: passed
39+
40+
Merged native/session/TUI regressions: 36 pass, 0 fail
41+
Merged npm regressions: 8 pass, 0 fail
42+
Merged opencode package typecheck: passed
2743
```
2844

2945
---
3046

3147
### 🔍 Verification
3248

33-
The slow-tool regression was observed failing before the fix and passing afterward through the real session processor and a local HTTP model endpoint. Additional cases cover parallel local tools and explicit cancellation. Formal publication requires independent review, SpecGit acceptance, and the main-branch Typecheck, Linux unit, and Linux/Windows E2E gates. Reported model usage in the regression is deterministic test input; no live model context limit is inferred from it.
49+
The slow-tool regression was observed failing before the fix and passing afterward through the real session processor and a local HTTP model endpoint. Additional cases cover parallel local tools and explicit cancellation. Independent Standards and Spec reviews found no code blockers. The integration statistics above come from [dev CI](https://github.com/LeXwDeX/OpenCode-GraphAgent/actions/runs/33868550950); they identify the tested baseline and do not substitute for the final release PR's Typecheck, Linux unit, Linux/Windows E2E and SpecGit acceptance gates. Reported model usage in the regression is deterministic test input; no live model context limit is inferred from it.
3450

3551
---
3652

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# ADR 0001: Event residue scrub and SQLite auto-vacuum reclamation
2+
3+
- **Status:** Accepted
4+
- **Date:** 2026-09-04
5+
- **Issue:** #524 (delivery: PR #537`dev`)
6+
- **Supersedes:** none
7+
8+
## Context
9+
10+
The durable event store had no reclamation semantics. `Event.remove(aggregateID)` ran only on
11+
explicit session removal and covered the session aggregate alone, and SQLite ran without
12+
`auto_vacuum`, so deleted rows never returned pages to the filesystem. Two failure shapes
13+
motivated the decision:
14+
15+
- A crash between the session-row delete and any cleanup stranded durable event aggregates
16+
whose `SessionTable` and `WorkflowTable` read models were both gone. Replaying such an
17+
aggregate is impossible: the `WorkflowCreated` projector INSERT dies on the
18+
`workflow.session_id` foreign key once the session row is gone (pinned by
19+
`packages/opencode/test/dag/dag-replay-idempotency.test.ts`), so residue can neither be
20+
replayed nor re-materialized — it can only be deleted.
21+
- `database.ts` initialized WAL and pragmas after driver open, so an application-level
22+
`PRAGMA auto_vacuum` could not take effect: after WAL initialization the pragma silently
23+
yields NONE even on an empty database.
24+
25+
The decision checkpoint was approved on 2026-09-04 with the scope locked below.
26+
27+
## Decisions
28+
29+
1. **Explicit session + dag scrub.** `Session.remove` captures every related dag aggregate ID
30+
before the `Deleted` publish (the projector's session-row delete FK-cascades the workflow
31+
rows inside the publish transaction, so a post-publish lookup would see nothing) and removes
32+
each dag event aggregate after the session aggregate — terminal workflows included. The
33+
per-dag scrub is soft-degrading (a failure is logged and the aggregate is left for the
34+
startup sweep) but preserves interruption (`Cause.hasInterrupts` re-raise, the
35+
`EventResidueSweep` sibling discipline).
36+
2. **Guarded default-on startup sweep.** `EventResidueSweep` runs one pass per process start,
37+
forked into the layer scope so it can neither block nor fail startup. Eligibility is the
38+
zero-live-read-model rule: an aggregate in `event_sequence` with neither a `session` nor a
39+
`workflow` row. Removal is a single atomic guarded `DELETE` that re-evaluates both
40+
NOT EXISTS guards inside the statement (no select-then-delete TOCTOU window), so an
41+
aggregate recreated concurrently survives. Wired into `AppLayer` and the HttpApiApp node
42+
graph, so every serving process sweeps; the pass is idempotent.
43+
3. **New databases: FULL before WAL.** Both SQLite drivers (`sqlite.bun.ts`, `sqlite.node.ts`)
44+
set `auto_vacuum=FULL` at the driver layer, before `journal_mode=WAL`, and only on a
45+
genuinely empty (0-page) file. An immediate SQLITE_BUSY from a second opener racing the
46+
first is tolerated: the pragma is a persistent header property and runs before any
47+
WAL/migration write, so the first-write winner sets FULL for the database.
48+
4. **Existing databases: explicit conversion only.** Legacy `auto_vacuum=NONE` databases are
49+
never converted at startup — startup is detect-only (a warning pointing at the command). The
50+
only conversion path is `opencode db vacuum --db <path>`: the target must be named
51+
explicitly and must already exist as a regular file (vacuum never creates a database), runs
52+
FULL → VACUUM → `wal_checkpoint(TRUNCATE)` outside any startup path, and fails nonzero
53+
unless the `PRAGMA auto_vacuum` readback is exactly FULL. Exclusive access is a hard
54+
requirement (a concurrent writer fails VACUUM with SQLITE_BUSY).
55+
5. **Archived-session retention: off and deferred.** No retention policy for archived sessions
56+
ships in this decision (Phase 3).
57+
6. **Active truncation: rejected.** Truncating active/retained session event history and event
58+
snapshot folding are rejected; incremental replay (`seq > after`, ascending) and sync
59+
cursors must keep observing unbroken per-aggregate histories.
60+
7. **`incremental_vacuum` is forbidden.** A disposable bun:sqlite prototype reproduced an
61+
exit-139 crash under the incremental mode; no code path may enable it.
62+
63+
## Consequences and risks
64+
65+
- Deleting events on legacy NONE databases still does not shrink the file until an operator
66+
runs the explicit conversion; disk usage grows until then.
67+
- `auto_vacuum=FULL` pays its known SQLite overhead (pointer-map pages, per-update mapping) on
68+
every new database in exchange for automatic page reclamation.
69+
- The sweep runs once per process start: residue created and abandoned within a single process
70+
lifetime waits for the next start. This is accepted because the shapes it targets are
71+
crash/in-flight zombies.
72+
- The conversion command requires exclusive access; the error guidance says to close running
73+
opencode processes and retry.
74+
- Replay and sync contracts are preserved by construction: only whole aggregates with no live
75+
read model are ever removed, and such aggregates are unreplayable anyway (FK death), so no
76+
consumer can observe the removal as a gap in a replayable history.
77+
78+
## Alternatives considered
79+
80+
- **Rely on replay instead of scrubbing** — rejected: a wiped dag aggregate whose session row
81+
is gone dies on the workflow foreign key during re-materialization, so replay cannot replace
82+
deletion.
83+
- **Silent startup conversion of legacy databases** — rejected: converting requires a blocking
84+
full VACUUM; startup stays non-blocking and detect-only.
85+
- **`PRAGMA incremental_vacuum`** — rejected (decision 7).
86+
- **A recurring background reaper** — rejected in favor of one idempotent guarded pass per
87+
process start; residue is crash-shaped, not steady-state throughput.
88+
- **Truncate or fold active event histories** — rejected (decision 6).
89+
90+
## Rollout and rollback
91+
92+
Rollout lands as ordinary PRs through `dev` per the release train; no operator action is
93+
required — new databases get FULL automatically, legacy databases keep working unchanged (with
94+
a detect-only warning), and the sweep is default-on. Rollback is removing the sweep from the
95+
app graphs and reverting the driver pragma: the sweep is additive and idempotent, and legacy
96+
databases were never written by any of this. A database created with FULL keeps its header
97+
mode; reverting one is itself an explicit operator VACUUM and is not automated.
98+
99+
## Acceptance
100+
101+
- Active/retained session replay is unchanged; only zero-live-read-model aggregates are
102+
removed (guarded delete re-checked inside the statement).
103+
- Cleanup failures never block the application path; interruption is preserved, not logged as
104+
failure.
105+
- Disposable-file tests demonstrate page reclamation and the new/existing database behavior;
106+
no startup-time full VACUUM exists.
107+
- `bun run test:dag-core`, focused event/session tests, package typecheck, and migration
108+
freshness checks pass in CI.
109+
110+
## Non-goals
111+
112+
- **No global bounded-retention claim.** Live and retained sessions keep their full event
113+
history indefinitely; this decision bounds nothing by age, size, or count.
114+
- **No tombstones, unarchive, or sync changes.** Offline deletion tombstones, unarchive
115+
semantics, and sync cursor/protocol changes stay out of scope.
116+
- **No authorization for #531 or live-database work.** This decision does not authorize running
117+
VACUUM or any cleanup against a live local database; the destructive operator procedure
118+
remains the human-only issue #531.

packages/core/src/database/database.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export * as Database from "./database"
22

33
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
44
import { layer as sqliteLayer } from "#sqlite"
5-
import { Context, Effect, Layer } from "effect"
5+
import { Cause, Context, Effect, Layer } from "effect"
6+
import { sql } from "drizzle-orm"
67
import { Global } from "../global"
78
import { Flag } from "../flag/flag"
89
import { isAbsolute, join } from "path"
@@ -29,6 +30,27 @@ export const layer = Layer.effect(
2930
yield* db.run("PRAGMA busy_timeout = 5000")
3031
yield* db.run("PRAGMA cache_size = -64000")
3132
yield* db.run("PRAGMA foreign_keys = ON")
33+
// #524: genuinely new databases were switched to auto_vacuum=FULL by the
34+
// sqlite driver BEFORE WAL init. A legacy database keeps its NONE mode —
35+
// converting one silently at startup would need a blocking full VACUUM —
36+
// so it is only detected and surfaced softly here; conversion is the
37+
// explicit user-triggered `opencode db vacuum --db <path>` command.
38+
// Detect-only means detect-only: a failed readback degrades to a warning
39+
// (the layer body is orDie'd, so an unhandled failure would kill startup),
40+
// while an interruption is always re-raised.
41+
const autoVacuum = yield* db.get<{ auto_vacuum: number }>(sql`PRAGMA auto_vacuum`).pipe(
42+
Effect.catchCause((cause) =>
43+
Cause.hasInterrupts(cause)
44+
? Effect.interrupt
45+
: Effect.logWarning("database auto_vacuum readback failed — skipping the detect-only check", { cause }).pipe(
46+
Effect.as(undefined),
47+
),
48+
),
49+
)
50+
if (autoVacuum?.auto_vacuum === 0)
51+
yield* Effect.logWarning(
52+
"database auto_vacuum is NONE — deleted pages stay allocated until converted; run `opencode db vacuum --db <path>` (prints its path with `opencode db path`)",
53+
)
3254
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
3355
yield* DatabaseMigration.apply(db)
3456

packages/core/src/database/sqlite.bun.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,40 @@ const nativeLayer = (config: Config) =>
161161
create: config.create ?? true,
162162
})
163163
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
164+
// #524: auto_vacuum must be set BEFORE any WAL initialization — after
165+
// WAL init the pragma silently yields NONE even on an empty database.
166+
// Only a genuinely empty (0-page) file is eligible: on any existing
167+
// database the pragma would be a no-op at best, so legacy NONE
168+
// databases are never written here (startup stays detect-only; the
169+
// explicit conversion lives in ./vacuum).
170+
native.run("PRAGMA busy_timeout = 5000;")
171+
if (config.readonly !== true) setAutoVacuumFull(native)
164172
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
165173
return native
166174
}),
167175
)
168176

177+
/**
178+
* Setting auto_vacuum needs the write lock of a read-header-then-write
179+
* upgrade, which SQLite fails with an immediate SQLITE_BUSY the busy handler
180+
* cannot retry — a second opener racing the first one's initialization on the
181+
* same new file hits it. Skipping on BUSY is safe: auto_vacuum is a
182+
* persistent header property and every opener runs this pragma BEFORE any
183+
* WAL/migration write, so whichever connection wins the first-write race sets
184+
* FULL for the database.
185+
*/
186+
function setAutoVacuumFull(native: Database) {
187+
const page = native.query<{ page_count: number }, []>("PRAGMA page_count").get()
188+
if (!page || page.page_count !== 0) return
189+
try {
190+
native.run("PRAGMA auto_vacuum = FULL;")
191+
} catch (cause) {
192+
if (!isSqliteBusy(cause)) throw cause
193+
}
194+
}
195+
196+
const isSqliteBusy = (cause: unknown) => cause instanceof Error && /SQLITE_BUSY|database is locked/i.test(cause.message)
197+
169198
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
170199

171200
const drizzleLayer = Layer.effect(

packages/core/src/database/sqlite.node.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,40 @@ const nativeLayer = (config: Config) =>
156156
open: true,
157157
})
158158
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
159+
// #524: auto_vacuum must be set BEFORE any WAL initialization — after
160+
// WAL init the pragma silently yields NONE even on an empty database.
161+
// On an existing non-empty database the pragma is a SQLite no-op, so
162+
// legacy NONE databases are never converted here (startup stays
163+
// detect-only; the explicit conversion lives in ./vacuum).
164+
native.exec("PRAGMA busy_timeout = 5000;")
165+
if (config.readonly !== true) setAutoVacuumFull(native)
159166
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
160167
return native
161168
}),
162169
)
163170

171+
/**
172+
* Setting auto_vacuum needs the write lock of a read-header-then-write
173+
* upgrade, which SQLite fails with an immediate SQLITE_BUSY the busy handler
174+
* cannot retry — a second opener racing the first one's initialization on the
175+
* same new file hits it. Skipping on BUSY is safe: auto_vacuum is a
176+
* persistent header property and every opener runs this pragma BEFORE any
177+
* WAL/migration write, so whichever connection wins the first-write race sets
178+
* FULL for the database.
179+
*/
180+
function setAutoVacuumFull(native: DatabaseSync) {
181+
const page: unknown = native.prepare("PRAGMA page_count").get()
182+
const pageCount = typeof page === "object" && page !== null && "page_count" in page ? page.page_count : undefined
183+
if (typeof pageCount !== "number" || pageCount !== 0) return
184+
try {
185+
native.exec("PRAGMA auto_vacuum = FULL;")
186+
} catch (cause) {
187+
if (!isSqliteBusy(cause)) throw cause
188+
}
189+
}
190+
191+
const isSqliteBusy = (cause: unknown) => cause instanceof Error && /SQLITE_BUSY|database is locked/i.test(cause.message)
192+
164193
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
165194

166195
const drizzleLayer = Layer.effect(

0 commit comments

Comments
 (0)