Files
fusion/packages/cli/src/lock-retry.ts
gsxdsm 97172fdcf2 fix(FN-7952): require PostgreSQL in CLI and desktop (#2110)
## Summary

CLI commands, daemon/dashboard startup, packaged desktop startup, and
live-data maintenance scripts now share the mandatory PostgreSQL
lifecycle. Operators no longer risk a command silently reading or
writing a disconnected SQLite shadow when PostgreSQL setup fails.

## Design decisions

- Every startup owner retains and awaits its PostgreSQL shutdown
callback, including partial-startup failure paths.
- CLI project context and lock-retry flows resolve through asynchronous
project stores.
- Maintenance scripts use the shared backend helper; explicit database
migration/inspection remains the only CLI surface allowed to read legacy
SQLite sources.

## Validation

- CLI and Desktop typechecks pass on the stacked branch.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 54 files.

## Stack

- Depends on #2109, which depends on #2108.
- Bundled plugins and docs/release follow in later PRs.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the authoritative store for structured project and
task metadata.
* Projects can be recognized and initialized using
`.fusion/project.json`, without creating a legacy SQLite database.
  * CLI commands now retry transient PostgreSQL contention errors.

* **Bug Fixes**
* Improved cleanup when commands complete, fail, or run in the
background, preventing lingering resources.
  * Improved desktop, server, and session shutdown reliability.

* **Documentation**
* Updated storage and standalone binary guidance to reflect PostgreSQL
and legacy SQLite compatibility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 23:18:55 -07:00

120 lines
4.8 KiB
TypeScript

/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* `fn task show`/`fn task move` (FN-7731, upstream #1976) open a `TaskStore`
* and call `getTask`/`moveTask` exactly once. If the engine or another agent
* can collide with another PostgreSQL transaction. Serialization failures,
* deadlocks, and lock-not-available errors are transient and should receive a
* bounded command-level retry rather than surfacing immediately.
*
* This module adds a CLI-level retry ABOVE that bound: it retries a thunk
* only when the error is classified as PostgreSQL contention. The legacy
* SQLite classifier remains accepted for isolated compatibility tests,
* with exponential backoff capped by a total wall-clock deadline. Non-lock
* errors (not-found, invalid column, etc.) propagate immediately — they are
* never retried. On deadline exhaustion the command fails fast with a
* clear, actionable, non-zero-exit error naming the task id and operation
* instead of hanging indefinitely. The default deadline is intentionally
* generous (long enough to ride out a typical engine write) while still
* bounded, and is operator-overridable via `FUSION_CLI_LOCK_RETRY_MS` for
* constrained/CI environments.
*
* Do NOT widen `@fusion/core`'s DB-level busy_timeout/lock-recovery window
* to "fix" this — that bound is intentionally tight so a single genuinely
* stuck writer does not block the whole process; this retry belongs at the
* CLI surface, above the DB-level bound, not inside it.
*/
import { isSqliteLockError } from "@fusion/core";
/** Default total wall-clock deadline (ms) for the lock-retry loop. */
export const DEFAULT_CLI_LOCK_RETRY_MS = 15_000;
const INITIAL_BACKOFF_MS = 100;
const MAX_BACKOFF_MS = 2_000;
/** Raised when a retried operation exhausts the bounded lock-retry deadline. */
export class LockRetryExhaustedError extends Error {
readonly cause: unknown;
constructor(message: string, cause: unknown) {
super(message);
this.name = "LockRetryExhaustedError";
this.cause = cause;
}
}
/**
* Read the operator-overridable total retry deadline from
* `FUSION_CLI_LOCK_RETRY_MS`, falling back to `DEFAULT_CLI_LOCK_RETRY_MS`
* for an unset/invalid value.
*/
export function getCliLockRetryDeadlineMs(): number {
const raw = process.env.FUSION_CLI_LOCK_RETRY_MS;
if (!raw) return DEFAULT_CLI_LOCK_RETRY_MS;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CLI_LOCK_RETRY_MS;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export interface RetryOnLockContext {
/** Task id the operation targets, used only for the error message. */
id: string;
/** Human-readable action name (e.g. "read task", "move task"), used only for the error message. */
action: string;
}
function isRetryableDatabaseContention(error: unknown): boolean {
if (isSqliteLockError(error)) return true;
if (!error || typeof error !== "object") return false;
const candidate = error as { code?: unknown; message?: unknown };
// PostgreSQL: serialization_failure, deadlock_detected, lock_not_available.
if (candidate.code === "40001" || candidate.code === "40P01" || candidate.code === "55P03") return true;
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
return message.includes("could not serialize access") || message.includes("deadlock detected") || message.includes("lock not available");
}
/**
* Run `operation`, retrying with bounded exponential backoff ONLY when the
* thrown error is a SQLite lock error (`isSqliteLockError`). Any other
* error (not-found, invalid column, etc.) propagates on the first attempt
* without retrying. On deadline exhaustion, throws `LockRetryExhaustedError`
* naming `context.id`/`context.action` with actionable guidance.
*/
export async function retryOnLock<T>(
operation: () => Promise<T>,
context: RetryOnLockContext,
totalMs: number = getCliLockRetryDeadlineMs(),
): Promise<T> {
const deadline = Date.now() + totalMs;
let backoff = INITIAL_BACKOFF_MS;
for (;;) {
try {
return await operation();
} catch (error) {
if (!isRetryableDatabaseContention(error)) {
throw error;
}
const now = Date.now();
if (now >= deadline) {
throw new LockRetryExhaustedError(
`Timed out after ${totalMs}ms waiting to ${context.action} for ${context.id}: ` +
`the board database stayed contended (the engine or another agent is writing). ` +
`Retry the command, or raise the bound via FUSION_CLI_LOCK_RETRY_MS.`,
error,
);
}
const remaining = deadline - now;
const wait = Math.min(backoff, remaining, MAX_BACKOFF_MS);
await sleep(wait);
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
}
}
}