Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-bootstrap-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@donadiosolutions/lcm": patch
---

Retry short authenticated root-bootstrap contention during a bounded window across CLI startup—20 total attempts at 50 ms intervals, up to about 950 ms—so read-only commands such as `lcm search` continue after a competing bootstrap completes, while ambiguous or unsafe lock states still fail closed.
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ This repo is a TypeScript SQLite daemon that persists Agent session memories acr
- Set `persist-credentials: false` on checkout steps in read-only workflows.
- Release-run recovery must read the canonical event tag from the workflow's strict `run-name`; do not infer historical tags from `head_branch`, a commit SHA, or another mutable/ref-derived field.
- Release ancestry depends on merging protected pull requests with merge commits. Keep release and maintenance commits as ancestors of `main`; do not squash or rebase those pull requests.
- Changesets and release notes describing bounded retries must match the implementation's attempt count, interval, and maximum wait; never call a multi-attempt window a single retry.
- Do not treat a GitHub pull request `base.sha`/OID as the merge commit's first-parent authority. Validate the exact merged PR and required base ref, canonical exact `merge_commit_sha`, exactly two canonical Git parents, and require `head.sha` as the second parent.
- Fallback and resolver paths require explicit eligibility guards from exact input evidence; ineligible inputs must preserve canonical diagnostics and avoid deeper remote/Git work, while eligible candidates still reach the resolver for fail-closed full-evidence validation.
- Normal and recovery npm publication must share the trusted tarball helper, require exactly one regular `.tgz`, pass npm an absolute filesystem path, and never invoke npm through a shell.
Expand Down
116 changes: 35 additions & 81 deletions bin/lcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,19 +207,37 @@ type DaemonRootOptions = {
help?: boolean;
};

const FOREGROUND_DAEMON_MIGRATION_ATTEMPTS = 20;
const FOREGROUND_DAEMON_MIGRATION_DELAY_MS = 50;
const ROOT_BOOTSTRAP_RETRY_ATTEMPTS = 20;
const ROOT_BOOTSTRAP_RETRY_DELAY_MS = 50;

export type ForegroundDaemonPreflightSeams = {
export type RootBootstrapRetrySeams = {
readonly migrate: () => unknown;
readonly sleep: (delayMs: number) => Promise<void>;
readonly attempt?: (attempt: number) => void;
};

const DEFAULT_FOREGROUND_DAEMON_PREFLIGHT_SEAMS: Omit<ForegroundDaemonPreflightSeams, "migrate"> = {
const DEFAULT_ROOT_BOOTSTRAP_RETRY_SEAMS: Omit<RootBootstrapRetrySeams, "migrate"> = {
sleep: (delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)),
};

export async function migrateLegacyHomeWithRetry(
seams: RootBootstrapRetrySeams,
): Promise<void> {
for (let attempt = 1; attempt <= ROOT_BOOTSTRAP_RETRY_ATTEMPTS; attempt += 1) {
seams.attempt?.(attempt);
try {
seams.migrate();
return;
} catch (error) {
if (!(error instanceof BootstrapLockContentionError)
|| attempt === ROOT_BOOTSTRAP_RETRY_ATTEMPTS) {
throw error;
}
await seams.sleep(ROOT_BOOTSTRAP_RETRY_DELAY_MS);
}
}
}

/** @internal Verifies that Commander preserved the preflighted hidden identity. */
export function assertParsedInternalDaemonTestIdentity(
opts: Pick<
Expand Down Expand Up @@ -353,71 +371,6 @@ function resolveInternalDaemonTestIdentity(
return identity;
}

/** @internal Exact argv gate for the only startup path allowed to retry migration contention. */
export function isForegroundDaemonStartArgv(cliArgv: readonly string[]): boolean {
const args = cliArgv.slice(2);
if (args[0] !== "daemon" || args[1] !== "start") return false;

let foreground = false;
let ownerCount = 0;
let entrypointCount = 0;
for (let index = 2; index < args.length; index += 1) {
const arg = args[index]!;
if (arg === "--foreground") {
if (foreground) return false;
foreground = true;
continue;
}
const inlineOwner = arg.startsWith(`${DAEMON_TEST_OWNER_OPTION}=`);
const inlineEntrypoint = arg.startsWith(`${DAEMON_TEST_ENTRYPOINT_OPTION}=`);
if (inlineOwner || inlineEntrypoint) {
const value = arg.slice(arg.indexOf("=") + 1);
if (value.length === 0) return false;
if (inlineOwner) ownerCount += 1;
else entrypointCount += 1;
continue;
}
if (arg === DAEMON_TEST_OWNER_OPTION || arg === DAEMON_TEST_ENTRYPOINT_OPTION) {
const value = args[index + 1];
if (value === undefined || value.startsWith("-")) return false;
if (arg === DAEMON_TEST_OWNER_OPTION) ownerCount += 1;
else entrypointCount += 1;
index += 1;
continue;
}
return false;
}

return foreground
&& ((ownerCount === 0 && entrypointCount === 0)
|| (ownerCount === 1 && entrypointCount === 1));
}

/** @internal Bounded migration preflight for the exact managed foreground daemon argv. */
export async function migrateLegacyHomeForForegroundDaemonStart(
cliArgv: readonly string[],
seams: ForegroundDaemonPreflightSeams,
): Promise<void> {
if (!isForegroundDaemonStartArgv(cliArgv)) {
seams.migrate();
return;
}

for (let attempt = 1; attempt <= FOREGROUND_DAEMON_MIGRATION_ATTEMPTS; attempt += 1) {
seams.attempt?.(attempt);
try {
seams.migrate();
return;
} catch (error) {
if (!(error instanceof BootstrapLockContentionError)
|| attempt === FOREGROUND_DAEMON_MIGRATION_ATTEMPTS) {
throw error;
}
await seams.sleep(FOREGROUND_DAEMON_MIGRATION_DELAY_MS);
}
}
}

/** Resolve custom help before Commander can dispatch a nested command action. */
function resolveCustomHelpRequest(cliArgv: string[]): CustomHelpRequest | undefined {
const args = cliArgv.slice(2);
Expand Down Expand Up @@ -1196,7 +1149,6 @@ async function createDaemonClientOrExit(
const { loadDaemonConfig } = await import("../src/daemon/config.js");
const { selectStorageBackendForConfig } = await import("../src/storage/backend.js");

migrateLegacyHomeIfNeeded();
const configFile = defaultConfigPath();
const config = loadDaemonConfig(configFile);
if (options.preflightStorage !== false) selectStorageBackendForConfig(configFile, config.storage);
Expand Down Expand Up @@ -1229,7 +1181,7 @@ async function createDaemonClientOrExit(
/** @internal CLI entry seam; defaults preserve the published executable behavior. */
export async function runCli(
cliArgv: string[] = process.argv,
preflightSeams?: ForegroundDaemonPreflightSeams,
preflightSeams?: RootBootstrapRetrySeams,
): Promise<void> {
const customHelp = resolveCustomHelpRequest(cliArgv);
if (customHelp && cliArgv.slice(2).length > 0) {
Expand All @@ -1246,15 +1198,11 @@ export async function runCli(

const internalDaemonTestIdentity = resolveInternalDaemonTestIdentity(cliArgv);
const migrate = preflightSeams?.migrate ?? migrateLegacyHomeIfNeeded;
if (isForegroundDaemonStartArgv(cliArgv)) {
await migrateLegacyHomeForForegroundDaemonStart(cliArgv, {
migrate,
sleep: preflightSeams?.sleep ?? DEFAULT_FOREGROUND_DAEMON_PREFLIGHT_SEAMS.sleep,
attempt: preflightSeams?.attempt,
});
} else {
migrate();
}
await migrateLegacyHomeWithRetry({
migrate,
sleep: preflightSeams?.sleep ?? DEFAULT_ROOT_BOOTSTRAP_RETRY_SEAMS.sleep,
attempt: preflightSeams?.attempt,
});
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const pkgPath = join(packageRootFor(import.meta.url, 2), "package.json");
Expand Down Expand Up @@ -2872,7 +2820,13 @@ export async function runCli(

/** @internal Top-level rejection handler kept separate for deterministic tests. */
export function handleCliError(err: unknown): never {
console.error(err instanceof ConfigValidationError || err instanceof StorageBackendUnavailableError ? err.message : err);
console.error(
err instanceof ConfigValidationError
|| err instanceof StorageBackendUnavailableError
|| err instanceof BootstrapLockContentionError
? err.message
: err,
);
return exit(1);
}

Expand Down
45 changes: 27 additions & 18 deletions docs/daemon-restart-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,24 +201,33 @@ lcm doctor
lcm daemon restart
```

## Foreground startup during root migration

The managed foreground process used by `lcm daemon restart` may briefly start
while another authenticated LCM command is migrating the user root. In that
narrow case, the foreground `start` invocation with its `--foreground` option
retries the migration preflight for at most 20 total attempts, with 50
milliseconds between attempts. The
maximum wait is therefore 950 milliseconds. Once the authenticated bootstrap
lock is released, startup continues normally.

The 20-attempt limit and 50-millisecond delay are fixed implementation values;
there is no user configuration for this foreground retry window.

Only the exact foreground start command uses this bounded retry. Ordinary LCM
commands remain fail-fast, and malformed, ambiguous, foreign, stale-recovery,
or other migration failures are never retried. If the live-lock contention
continues through the bounded window, inspect the state with `lcm doctor` and
retry `lcm daemon restart` once the competing operation has completed.
## Root-bootstrap contention during CLI startup

Any non-help `lcm` invocation may briefly overlap another authenticated LCM
operation that is bootstrapping the user root. The CLI retries that one
root-bootstrap migration boundary for at most 20 total attempts, with 50
milliseconds between attempts. The fixed maximum wait is therefore 950
milliseconds per invocation; there is no user configuration for this budget.
After a successful preflight, daemon-backed commands do not perform a second
bootstrap migration.

Retry is enabled only when the runtime has authenticated a verified live owner
of the bootstrap lock and raises `BootstrapLockContentionError`. The CLI does
not inspect, delete, rename, or reclaim the lock while retrying. If the
competing LCM operation completes within the budget, the original command
continues normally, including ordinary commands such as `lcm search`.

Ambiguous or unavailable owner liveness, malformed or tampered metadata,
stale-lock recovery already in progress, a lock changed during stale-owner
recovery, and a concurrent successor or reclaim claim remain immediate
fail-closed errors. Those states do not prove one authenticated live bootstrap
owner and are never converted into retryable contention.

When the verified live owner remains through all 20 attempts, LCM reports that
automatic lock recovery was not attempted. Retry after the competing LCM
operation completes, and do not delete the bootstrap lock manually. `lcm doctor`
does not diagnose the root-bootstrap lock; use the safe message from
the failed invocation and retry only after the competing operation has ended.

## Configuration and security boundary

Expand Down
Loading
Loading