Files
fusion/packages/cli/src/lock-retry.ts
gsxdsm 7420abe80e FN-7731: add CLI-level lock retry for task show/move commands
Add bounded exponential-backoff retry above the DB layer so `fn task show`/`fn task move` ride out transient SQLite lock contention instead of failing outright or hanging.

- Add packages/cli/src/lock-retry.ts: retries a thunk on SQLite lock errors (via @fusion/core's isSqliteLockError) with exponential backoff capped by a wall-clock deadline (default 15s, override via FUSION_CLI_LOCK_RETRY_MS); non-lock errors propagate immediately; raises LockRetryExhaustedError on deadline exhaustion.
- Wire lock-retry into packages/cli/src/commands/task.ts for the show/move task-store operations, and close the resolved TaskStore for deterministic exit.
- Export isSqliteLockError from packages/core/src/index.ts for CLI reuse.
- Add packages/cli/src/commands/__tests__/task-lock-retry.test.ts covering retry/backoff/deadline/error-passthrough behavior; extend task.test.ts.
- Document the new behavior/env var in docs/cli-reference.md.
- Add changeset (@runfusion/fusion: patch).

Files changed:
 .changeset/fn-7731-task-cmd-lock-retry.md          |   7 +
 docs/cli-reference.md                              |   9 +
 .../src/commands/__tests__/task-lock-retry.test.ts | 430 +++++++++++++++++++++
 packages/cli/src/commands/__tests__/task.test.ts   |  11 +
 packages/cli/src/commands/task.ts                  | 117 +++++-
 packages/cli/src/lock-retry.ts                     | 117 ++++++
 packages/core/src/index.ts                         |   5 +
 7 files changed, 688 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7731

Fusion-Task-Lineage: 85543164-10d6-4da9-8c3c-a84cd86827aa

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-09 19:58:05 -07:00

118 lines
4.6 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
* holds a SQLite writer lock on `.fusion/fusion.db` at that instant, the
* call surfaces a raw `database is locked` error (or appears to hang until
* the DB layer's own bounded `busy_timeout`/lock-recovery window in
* packages/core/src/db.ts — DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5s plus a short
* lock-recovery retry — finally gives up). That DB-level bound already
* prevents an unbounded hang at the SQLite layer, but it does not retry
* across separate `better-sqlite3`/node:sqlite statement calls, so a single
* unlucky read/write at the CLI surface still fails outright even though
* the lock typically clears within a second or two of normal engine
* activity.
*
* This module adds a CLI-level retry ABOVE that bound: it retries a thunk
* only when the error is classified as a SQLite lock error (reusing
* `@fusion/core`'s `isSqliteLockError`, the same classifier the DB layer's
* own `runWithLockRecovery` uses, so CLI and DB lock detection never drift),
* 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;
}
/**
* 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 (!isSqliteLockError(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 locked (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);
}
}
}