Skip to content

Commit c39bd75

Browse files
anandgupta42claude
andcommitted
fix: [#1203] a broken warehouse connection reached the agent as "(0 rows)"
Field verification against the real rig showed the first version of this fix did not help: `--dir` still returned "no tables", nothing was created anywhere on disk, and `assertStoreExists()` produced no visible error even when pointed at a store that does not exist. The diagnosis was wrong, not the measurement. A failure was shaped like success at every layer between the driver and the model, so three separate things had to be true for the agent to be misled, and all three were: 1. `drivers/src/duckdb.ts` and `drivers/src/sqlite.ts` read `(config.path as string) ?? ":memory:"`. Any failure to carry a path — a config the registry never loaded, a field under another name, a lookup that fell through — became a successful connection to an empty in-memory database. That is worse than the silent creation this PR already closed: a stray file can at least be found afterwards, whereas this leaves nothing on disk to explain the empty answer. `requireStorePath()` now rejects a missing or empty path. `:memory:` is still available, but only when asked for. 2. `sql.execute` never throws. It catches every connection and query error and returns `{ columns: [], rows: [], row_count: 0, error }` (native/connections/register.ts:437). So the loud error from (1) and from `assertStoreExists()` was demoted to a field on a success-shaped result. 3. `formatResult()` in `tools/sql-execute.ts` returned the literal string `"(0 rows)"` whenever `row_count === 0` and never read `error`. An agent running `SELECT ... FROM information_schema.tables` against an unresolvable warehouse was told, in effect, that the warehouse was healthy and empty — then offered a tip about query optimization. `sql_execute` now surfaces the error the way `schema_inspect` already did, via `normalizeError`. So `assertStoreExists()` was never bypassed. It fired, and its message was thrown away one layer above. A guard whose failure cannot reach the caller is not a guard, which is why the new tests drive the layers the product actually uses: the compiled-binary arms now go through `Dispatcher.call("sql.execute")` rather than reaching the driver via `Registry.get()`, and the rendering is tested through the `sql_execute` tool itself. `test/altimate/warehouse-failure-visibility.test.ts` covers all three: a pathless config, a missing store, and an error-carrying result that must not render as `(0 rows)` — plus the two cases that must NOT change, an explicit `:memory:` and a genuinely empty result set from a healthy warehouse. Three of its six fail on unmodified main, as do three of the five compiled-binary arms. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ
1 parent e3699ea commit c39bd75

7 files changed

Lines changed: 247 additions & 13 deletions

File tree

‎packages/drivers/src/duckdb.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* DuckDB driver using the `duckdb` package.
33
*/
44

5-
import { assertStoreExists } from "./file-store"
5+
import { assertStoreExists, requireStorePath } from "./file-store"
66
import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types"
77
import { loadOptionalDriver } from "./resolve"
88

@@ -11,7 +11,9 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
1111
duckdb = await loadOptionalDriver("duckdb", "duckdb")
1212
duckdb = duckdb.default || duckdb
1313

14-
const dbPath = (config.path as string) ?? ":memory:"
14+
// altimate_change start — a missing path must fail loudly, not become :memory:
15+
const dbPath = requireStorePath(config, "DuckDB")
16+
// altimate_change end
1517
let db: any
1618
let connection: any
1719

‎packages/drivers/src/file-store.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ export function isLocalFilePath(dbPath: string): boolean {
3939
return true
4040
}
4141

42+
/**
43+
* The store path a file-backed connection names, or a loud failure.
44+
*
45+
* Both drivers used to read `(config.path as string) ?? ":memory:"`. That turns
46+
* ANY failure to carry a path — a config the registry never loaded, a field
47+
* under the wrong name, a lookup that fell through — into a successful
48+
* connection over an empty in-memory database. Every query then returns no rows
49+
* and no error, which reads as a healthy warehouse that happens to be empty.
50+
*
51+
* It is a worse failure than creating a store on disk: a stray file can at
52+
* least be found afterwards, whereas an in-memory database leaves nothing
53+
* behind to explain the empty answer. `:memory:` remains available, but only
54+
* when a caller asks for it by name.
55+
*/
56+
export function requireStorePath(config: ConnectionConfig, engine: string): string {
57+
const value = config.path
58+
if (typeof value === "string" && value !== "") return value
59+
throw new Error(
60+
`${engine} connection is missing its "path". A file-backed warehouse must name its database explicitly — ` +
61+
`falling back to an in-memory database would answer every query with no rows and no error, ` +
62+
`which is indistinguishable from a healthy but empty warehouse. ` +
63+
`Set "path" to the database file, or to ":memory:" if a throwaway empty database is genuinely what you want.`,
64+
)
65+
}
66+
4267
/** Whether the caller explicitly opted in to creating the store. */
4368
export function allowsCreate(config: ConnectionConfig): boolean {
4469
return config.create === true

‎packages/drivers/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig } from
55
export { normalizeConfig, sanitizeConnectionString } from "./normalize"
66

77
// Re-export file-backed store guards
8-
export { allowsCreate, assertStoreExists, isLocalFilePath } from "./file-store"
8+
export { allowsCreate, assertStoreExists, isLocalFilePath, requireStorePath } from "./file-store"
99

1010
// Re-export driver connect functions
1111
export { connect as connectPostgres } from "./postgres"

‎packages/drivers/src/sqlite.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
*/
55

66
import { Database } from "bun:sqlite"
7-
import { allowsCreate, assertStoreExists } from "./file-store"
7+
import { allowsCreate, assertStoreExists, requireStorePath } from "./file-store"
88
import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types"
99

1010
export async function connect(config: ConnectionConfig): Promise<Connector> {
11-
const dbPath = (config.path as string) ?? ":memory:"
11+
// altimate_change start — a missing path must fail loudly, not become :memory:
12+
const dbPath = requireStorePath(config, "SQLite")
13+
// altimate_change end
1214
let db: Database | null = null
1315

1416
return {

‎packages/opencode/src/altimate/tools/sql-execute.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import * as Registry from "../native/connections/registry"
1616
// altimate_change start — workspace precedence
1717
import * as Precedence from "../workspace/precedence"
1818
// altimate_change end
19+
// altimate_change start — never render a warehouse failure as an empty result
20+
import { normalizeError } from "./response-normalization"
21+
// altimate_change end
1922

2023
export const SqlExecuteTool = Tool.define("sql_execute", {
2124
description: "Execute SQL against a connected data warehouse. Returns results as a formatted table.",
@@ -69,6 +72,22 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
6972
limit: args.limit,
7073
})
7174

75+
// altimate_change start — a failure must not be rendered as "(0 rows)".
76+
// sql.execute never throws: it catches every connection and query error
77+
// and returns a result-shaped object carrying `error`, so an unresolvable
78+
// warehouse used to reach the agent as a successful empty table with no
79+
// fault string at all. Surface it the way schema_inspect already does.
80+
const responseError = normalizeError((result as SqlExecuteResult & { error?: unknown }).error)
81+
if (responseError !== undefined) {
82+
const msg = responseError.trim() || "SQL execution failed."
83+
return {
84+
title: "SQL: ERROR",
85+
metadata: { rowCount: 0, truncated: false, error: msg },
86+
output: `Failed to execute SQL: ${msg}`,
87+
}
88+
}
89+
// altimate_change end
90+
7291
let output = formatResult(result)
7392
// altimate_change start — emit SQL structure fingerprint telemetry
7493
try {
@@ -137,7 +156,11 @@ interface PreValidationResult {
137156
error?: string
138157
}
139158

140-
async function preValidateSql(sql: string, warehouse: string | undefined, queryType: string): Promise<PreValidationResult> {
159+
async function preValidateSql(
160+
sql: string,
161+
warehouse: string | undefined,
162+
queryType: string,
163+
): Promise<PreValidationResult> {
141164
const startTime = Date.now()
142165
// Yield the event loop before heavy synchronous SQLite work so concurrent
143166
// tasks aren't blocked. Bun's sqlite API is sync and listColumns can touch

‎packages/opencode/test/altimate/fixtures/store-path-probe.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,29 @@ async function main() {
3535
const connection = arg("connection") ?? "probe"
3636
const schema = arg("schema") ?? "main"
3737

38-
const read = async () => {
39-
const connector = await Registry.get(connection)
40-
const tables = await connector.listTables(schema)
41-
await connector.close()
42-
return tables.map((t) => t.name).sort()
38+
// `--via dispatcher` (the default) drives the same entry point the CLI's
39+
// sql_execute tool uses: Dispatcher.call -> sql.execute -> Registry -> driver.
40+
// Reaching the driver any other way can pass while the product path is broken,
41+
// which is exactly how a guard gets shipped that never runs.
42+
const via = arg("via") ?? "dispatcher"
43+
44+
const read = async (): Promise<{ tables: string[]; error?: string }> => {
45+
if (via === "registry") {
46+
const connector = await Registry.get(connection)
47+
const tables = await connector.listTables(schema)
48+
await connector.close()
49+
return { tables: tables.map((t) => t.name).sort() }
50+
}
51+
// Import the native index, not the dispatcher module: the index is what
52+
// installs the lazy registration hook in production. Importing the
53+
// dispatcher alone yields "No native handler for sql.execute".
54+
const { Dispatcher } = await import("../../../src/altimate/native")
55+
const result = (await Dispatcher.call("sql.execute", {
56+
sql: "SELECT name FROM sqlite_master WHERE type = 'table'",
57+
warehouse: connection,
58+
})) as { rows?: unknown[][]; error?: unknown }
59+
if (result.error !== undefined) return { tables: [], error: String(result.error) }
60+
return { tables: (result.rows ?? []).map((r) => String(r[0])).sort() }
4361
}
4462

4563
// `--instance-dir` models a server or `run --attach` request: the working
@@ -51,15 +69,21 @@ async function main() {
5169
if (!instanceDir && dir) process.chdir(dir)
5270

5371
try {
54-
const tables = instanceDir
72+
const outcome = instanceDir
5573
? await (
5674
await import("../../../src/project/instance")
5775
).Instance.provide({
5876
directory: instanceDir,
5977
fn: read,
6078
})
6179
: await read()
62-
console.log(JSON.stringify({ ok: true, cwd: process.cwd(), tables }))
80+
console.log(
81+
JSON.stringify(
82+
outcome.error !== undefined
83+
? { ok: false, cwd: process.cwd(), error: outcome.error }
84+
: { ok: true, cwd: process.cwd(), tables: outcome.tables },
85+
),
86+
)
6387
} catch (e) {
6488
console.log(
6589
JSON.stringify({
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* A warehouse failure must never reach the agent as an empty result.
3+
*
4+
* Field report against the real rig: a compiled binary returned "no tables"
5+
* with no error and no file created anywhere on disk. The cause was not the
6+
* silent-creation path — it is that a failure was shaped like success all the
7+
* way to the model:
8+
*
9+
* 1. `packages/drivers/src/duckdb.ts` read `(config.path as string) ?? ":memory:"`,
10+
* so a config that arrived without a `path` became an in-memory database.
11+
* Nothing is created, nothing is on disk, every query returns no rows.
12+
* 2. `sql.execute` never throws. It catches every connection and query error
13+
* and returns `{ columns: [], rows: [], row_count: 0, error }`
14+
* (native/connections/register.ts).
15+
* 3. `formatResult()` rendered that as the literal string `"(0 rows)"` and
16+
* never read `error` — so the agent saw a healthy empty table.
17+
*
18+
* The layers are tested where each one breaks: the guard against the real
19+
* registry, and the rendering against a stubbed `sql.execute` that returns the
20+
* error-carrying shape the real handler produces. The stub is deliberate —
21+
* `Dispatcher.reset()` leaks between test files in Bun, and the true
22+
* end-to-end through the live dispatcher is covered by the compiled-binary
23+
* test in store-path-resolution.test.ts, which runs in its own process.
24+
*/
25+
26+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
27+
import { Database } from "bun:sqlite"
28+
import * as fs from "fs"
29+
import * as os from "os"
30+
import * as path from "path"
31+
import { initTool } from "./tool-fixture"
32+
import * as Dispatcher from "../../src/altimate/native/dispatcher"
33+
import * as Registry from "../../src/altimate/native/connections/registry"
34+
import { SqlExecuteTool } from "../../src/altimate/tools/sql-execute"
35+
36+
/** Unguessable, so a pass cannot come from anything but the real store. */
37+
const CANARY_TABLE = "zorbulax_ledger"
38+
39+
const ctx = {
40+
sessionID: "test-session",
41+
messageID: "test-message",
42+
callID: "test-call",
43+
agent: "test",
44+
abort: new AbortController().signal,
45+
messages: [],
46+
metadata: () => {},
47+
}
48+
49+
const tmpDirs: string[] = []
50+
function tmp(): string {
51+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "warehouse-failure-"))
52+
tmpDirs.push(dir)
53+
return dir
54+
}
55+
56+
beforeEach(() => {
57+
process.env.ALTIMATE_TELEMETRY_DISABLED = "true"
58+
Registry.reset()
59+
})
60+
61+
afterEach(() => {
62+
Registry.reset()
63+
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true })
64+
})
65+
66+
const LIST_TABLES = `SELECT name FROM sqlite_master WHERE type = 'table'`
67+
68+
describe("a file-backed connection that cannot be resolved fails loudly", () => {
69+
test("a config with no path is rejected instead of becoming an in-memory database", async () => {
70+
// The reported shape: nothing on disk, no error, every query empty. A
71+
// `?? ":memory:"` default turns any failure to carry a path into a
72+
// successful query against nothing.
73+
Registry.setConfigs({ wh: { type: "sqlite" } })
74+
75+
await expect(Registry.get("wh")).rejects.toThrow(/path/)
76+
})
77+
78+
test("a missing store file is rejected, and no store is conjured", async () => {
79+
const missing = path.join(tmp(), "definitely-absent.db")
80+
Registry.setConfigs({ wh: { type: "sqlite", path: missing } })
81+
82+
await expect(Registry.get("wh")).rejects.toThrow(/not found/)
83+
expect(fs.existsSync(missing)).toBe(false)
84+
})
85+
86+
test("an explicit :memory: is still honoured — it just has to be asked for", async () => {
87+
Registry.setConfigs({ wh: { type: "sqlite", path: ":memory:" } })
88+
89+
const connector = await Registry.get("wh")
90+
const result = await connector.execute("SELECT 1 AS n")
91+
expect(result.rows[0][0]).toBe(1)
92+
})
93+
94+
test("a populated store still opens and returns its real tables", async () => {
95+
const store = path.join(tmp(), "warehouse.db")
96+
const seed = new Database(store, { create: true })
97+
seed.exec(`CREATE TABLE ${CANARY_TABLE}(id INTEGER)`)
98+
seed.close()
99+
Registry.setConfigs({ wh: { type: "sqlite", path: store } })
100+
101+
const connector = await Registry.get("wh")
102+
const result = await connector.execute(LIST_TABLES)
103+
expect(result.rows.map((r) => r[0])).toContain(CANARY_TABLE)
104+
})
105+
})
106+
107+
describe("sql_execute renders a warehouse failure as a failure", () => {
108+
// Reset first, then register: `call()` runs the lazy registration hook
109+
// before dispatching, and that would re-import the real handler and clobber
110+
// the stub. This is the same order tool-response-normalization.test.ts uses.
111+
beforeEach(() => {
112+
Dispatcher.reset()
113+
})
114+
115+
/** The exact shape sql.execute returns for any connection or query error. */
116+
function stubFailure(message: string) {
117+
Dispatcher.register("sql.execute" as any, async () => ({
118+
columns: [],
119+
rows: [],
120+
row_count: 0,
121+
truncated: false,
122+
error: message,
123+
}))
124+
}
125+
126+
test("an error-carrying result is reported, not printed as (0 rows)", async () => {
127+
// This is the step that turned a broken connection into "no tables":
128+
// formatResult() returned "(0 rows)" for row_count === 0 and never looked
129+
// at `error`, so the model was told the warehouse was simply empty.
130+
stubFailure('SQLite database file not found: "/nowhere/absent.db".')
131+
132+
const tool = await initTool(SqlExecuteTool)
133+
const result = await tool.execute({ query: LIST_TABLES }, ctx as any)
134+
135+
expect(result.output).not.toContain("(0 rows)")
136+
expect(result.title).toContain("ERROR")
137+
expect(result.output).toContain("not found")
138+
expect(result.metadata.error).toBeDefined()
139+
})
140+
141+
test("a genuinely empty result set is still reported as empty, not as an error", async () => {
142+
// The distinction that matters: zero rows from a healthy warehouse is not
143+
// a failure, and must not start reporting as one.
144+
Dispatcher.register("sql.execute" as any, async () => ({
145+
columns: ["name"],
146+
rows: [],
147+
row_count: 0,
148+
truncated: false,
149+
}))
150+
151+
const tool = await initTool(SqlExecuteTool)
152+
const result = await tool.execute({ query: LIST_TABLES }, ctx as any)
153+
154+
expect(result.title).not.toContain("ERROR")
155+
expect(result.output).toContain("(0 rows)")
156+
expect(result.metadata.error).toBeUndefined()
157+
})
158+
})

0 commit comments

Comments
 (0)