feat(sqlite): make worker deadlines adjustable with recovery guidance (#5818)

Expose a per-call SQLite worker deadline with exact CLI/MCP recovery controls,
parent-owned execution metadata and historical unknown preservation. Keep fixed
resource limits and cleanup admission intact. Share typed cleanup composition
with one captured-output owner and preserve the timeout after cleanup resolves.

Fixes #5812. Follow-up tracking: #5808. Existing CLI test-oracle failure: #5845.

Verified 155 SQLite tests (3 skipped), static/docs/discovery checks, six real
CLI/MCP pairs and 13 actual Codex sessions including four corrected deadline
recoveries. Three pre-dispatch discovery failures remain explicit, not passes.
Review findings include real retained-cleanup and fixture ownership regressions.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
gensokyo-dev
2026-10-21 16:45:46 +08:00
committed by GitHub
parent 5241597594
commit f801b2fbfd
15 changed files with 496 additions and 49 deletions
+91 -27
View File
@@ -8,7 +8,9 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
AnalysisError,
type AnalysisCapturedOutput,
type AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
@@ -23,8 +25,11 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/sqliteDatabase.js";
import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js";
import {
SQLITE_PROVIDER_IDENTITY,
DEFAULT_SQLITE_WORKER_TIMEOUT_MS,
MAX_SQLITE_WORKER_TIMEOUT_MS,
} from "../domain/sqlite/sqliteDatabase.js";
/** Preserve diagnostics independently of worker exit and private-root cleanup. */
export const capturedSqliteOutput = (
@@ -35,6 +40,38 @@ export const capturedSqliteOutput = (
truncated: snapshot.diagnosticTruncated === true,
});
/** Keep typed cleanup history and one canonical output owner, including after resolution. */
export const createSqliteCleanupError = (
resources: readonly string[],
diagnostics: Readonly<Record<string, JsonValue>>,
options: Omit<AnalysisErrorOptions, "diagnostics">,
): ProviderCleanupError => {
const capturedOutput =
options.capturedOutput ?? options.cleanupPrimary?.capturedOutput;
let previous =
options.cleanupPrimary === undefined
? null
: projectAnalysisError(options.cleanupPrimary);
// History carries facts/recovery; the wrapper delivers output only once.
if (capturedOutput !== undefined && previous?.details !== undefined) {
const { captured_output: _capturedOutput, ...metadata } = previous.details;
previous = { ...previous, details: metadata };
}
return new ProviderCleanupError(
SQLITE_PROVIDER_IDENTITY.id,
resources,
{
...diagnostics,
previous_error: previous ?? diagnostics.previous_error ?? null,
},
{
...options,
operation: "inspect_sqlite_database",
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
);
};
/** The file-set member a filesystem error names, if it is the database, its WAL or its journal. */
const busyMember = (cause: Error, path: string): string => {
const failed = "path" in cause ? cause.path : undefined;
@@ -50,6 +87,7 @@ export const sqliteDatabaseFailure = (
path: string,
phase: string,
signal?: AbortSignal,
workerTimeoutMs = DEFAULT_SQLITE_WORKER_TIMEOUT_MS,
): AnalysisError => {
const operation = "inspect_sqlite_database";
if (cause instanceof AnalysisError) return cause;
@@ -61,28 +99,36 @@ export const sqliteDatabaseFailure = (
: undefined;
let previous: AnalysisError | undefined;
if (physicalRead?.kind === "failed")
previous = sqliteDatabaseFailure(physicalRead.cause, path, phase);
previous = sqliteDatabaseFailure(
physicalRead.cause,
path,
phase,
undefined,
workerTimeoutMs,
);
else if (cause.reason === "cancelled")
previous = new AnalysisCancelledError(operation, {
cause: artifactFileCancellationCause(cause),
});
else if (physicalRead?.kind === "completed") previous = undefined;
else if (cause.cause !== undefined)
previous = sqliteDatabaseFailure(cause.cause, path, phase);
previous = sqliteDatabaseFailure(
cause.cause,
path,
phase,
undefined,
workerTimeoutMs,
);
else if (signal?.aborted)
previous = new AnalysisCancelledError(operation, {
cause: signal.reason,
});
const error = new ProviderCleanupError(
SQLITE_PROVIDER_IDENTITY.id,
const error = createSqliteCleanupError(
cause.cleanup.resources,
{
reason: cause.cleanup.reason,
previous_error:
previous === undefined ? null : projectAnalysisError(previous),
},
{
operation,
cause,
...(previous === undefined ? {} : { cleanupPrimary: previous }),
},
@@ -91,7 +137,13 @@ export const sqliteDatabaseFailure = (
return error;
}
if (cause.reason === "io" && cause.cause !== undefined)
return sqliteDatabaseFailure(cause.cause, path, phase, signal);
return sqliteDatabaseFailure(
cause.cause,
path,
phase,
signal,
workerTimeoutMs,
);
if (cause.reason === "integrity")
return new AnalysisArtifactChangedError(operation, path, cause.message, {
cause,
@@ -116,7 +168,7 @@ export const sqliteDatabaseFailure = (
: cause,
});
if (cause instanceof OwnedCommandFailure) {
const projected = ownedCommandFailure(cause);
const projected = ownedCommandFailure(cause, workerTimeoutMs);
if (projected !== undefined) return projected;
}
if (phase === "artifact-read" && cause instanceof Error && "code" in cause) {
@@ -173,6 +225,7 @@ export const sqliteDatabaseFailure = (
const ownedCommandFailure = (
cause: OwnedCommandFailure,
workerTimeoutMs: number,
): AnalysisError | undefined => {
const operation = "inspect_sqlite_database";
const capturedOutput =
@@ -181,21 +234,37 @@ const ownedCommandFailure = (
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
};
// Preserve the same typed primary whether or not owned cleanup also fails.
const timeout =
cause.reason === "timeout"
? new AnalysisTimeoutError(operation, workerTimeoutMs, {
...options,
userMessage: `The SQLite worker reached its ${String(workerTimeoutMs)} ms server/provider deadline (worker_timeout_ms). This is not a whole-inspection or MCP client deadline.`,
remediationAction: `${
workerTimeoutMs < MAX_SQLITE_WORKER_TIMEOUT_MS
? `Increase worker_timeout_ms above ${String(workerTimeoutMs)} (integer milliseconds, maximum ${String(MAX_SQLITE_WORKER_TIMEOUT_MS)}), or use rea inspect-sqlite-database <path> --worker-timeout-ms <milliseconds> on the CLI.`
: `worker_timeout_ms is already at the maximum supported ${String(MAX_SQLITE_WORKER_TIMEOUT_MS)} ms; no higher worker deadline is supported. Report the blocker or narrow the question with caller approval.`
} This per-call setting needs no server restart. It covers worker startup, queries and reply writing, not snapshot acquisition or final reply reads/cleanup. Allow client headroom for the whole call; increasing only the client deadline does not change the worker budget. Keep the selected database/WAL and requested facts; narrow table/columns/schema_scope/row_limit only if the question permits it. Byte and heap safety limits remain fixed. Resolve incomplete cleanup before retrying; do not repeat the unchanged failed call.`,
})
: undefined;
if (cause.cleanupFailure !== null)
return new ProviderCleanupError(
SQLITE_PROVIDER_IDENTITY.id,
return createSqliteCleanupError(
cause.resources,
{
reason: cause.cleanupFailure,
previous_error: {
failure_kind: cause.reason,
message: cause.message,
},
...(capturedOutput === undefined
? {}
: { captured_output: { ...capturedOutput } }),
...(timeout === undefined
? {
previous_error: {
failure_kind: cause.reason,
message: cause.message,
},
}
: {}),
},
{
...options,
...(timeout === undefined ? {} : { cleanupPrimary: timeout }),
},
{ operation, cause },
);
if (cause.reason === "cancelled")
return new AnalysisCancelledError(operation, {
@@ -203,12 +272,7 @@ const ownedCommandFailure = (
cause:
cause.cancellation === undefined ? cause : cause.cancellation.reason,
});
if (cause.reason === "timeout")
return new AnalysisTimeoutError(
operation,
SQLITE_DATABASE_LIMITS.timeoutMs,
options,
);
if (timeout !== undefined) return timeout;
if (cause.reason === "output-limit")
return new AnalysisOutputError(operation, cause.message, options);
if (