Boot Failures
What each BootResult.reason renders on the crash screen, and when it fires
createApp returns a typed BootResult. A permanent failure is a named { ok: false, reason, detail }, and mount() renders it on the crash screen. A retryable unreadiness no longer stops boot: the app starts, and the success branch carries a notReady field so the caller can say the engine is not answering yet. A thrown failure is still caught and named too.
import { createApp, type BootResult } from "praisonai-mobile/app/boot";
const booted: BootResult = await createApp(deps);
if (!booted.ok) {
renderFatal(root, strings.bootFailed(booted.detail)); // permanent failure
} else if (booted.notReady) {
render(strings.engineNotReady(booted.notReady.detail)); // booted, but warn
}Only a permanent unreadiness (version_mismatch) reaches the failure branch. A retryable unreadiness โ a still-starting engine, an unreachable phone-to-desktop socket โ is delivered as a usable app plus an attached warning, not a refusal.
export type BootResult =
| {
readonly ok: true;
readonly app: App;
/** The app started, but the engine is not answering yet. A caller must
* SAY so -- silently booting into an engine that cannot reply is how a
* first message fails with no explanation. */
readonly notReady?: { readonly reason: string; readonly detail: string };
}
| {
readonly ok: false;
readonly reason: string;
readonly detail: string;
/** Set when the failure is a health probe that might succeed on a retry.
* In normal flow this is effectively always unset now -- retryable
* outcomes take the SUCCESS branch with `notReady` instead. */
readonly retryable?: boolean;
};Quick Start
- Handle a failed boot
mount()checksbooted.okand renders the crash screen with the detail. There is nothing to configure โ a named failure always reaches the screen.ts if (!booted.ok) { renderFatal(root, strings.bootFailed(booted.detail)); return null; } - Handle an engine that isn't answering yet
A retryable unreadiness โ a still-starting engine, an unreachable remote โ no longer reaches the
!booted.okbranch. The app booted;booted.notReadycarries the reason so you can say so. Sending later retries it.ts if (booted.ok && booted.notReady) { // The app is up. Say the engine is not ready; sending will retry. render(strings.engineNotReady(booted.notReady.detail)); } - A permanent failure still stops boot
A
version_mismatchโ a build that cannot speak the server’s protocol โ never fixes itself on a retry, so it stays on the failure branch and reaches the crash screen.ts if (!booted.ok && booted.reason === "version_mismatch") { renderFatal(root, strings.bootFailed(booted.detail)); // waiting cannot help } - Turn a throw into a reason
bootOrFailwrapscreateAppso an unanticipated throw โ aStoragePortfailure atsettingsStore.load()โ becomes the same typed shape rather than an uncaught rejection.ts try { return await createApp(options); } catch (error) { return { ok: false, reason: "storage_unavailable", detail: String(error) }; }
What the crash screen guarantees
The fatal screen carries three properties, each pinned by a test.
- It announces. The fatal element is
role="alert", notrole="status", so a screen reader speaks it the moment it mounts. - It names the real failure. The message is
booted.detail, the actual failure detail โ never a generic “something went wrong”. - It replaces the chrome.
root.textContent = ""runs first, so the dead app UI is gone; the fatal element is not appended under the corpse of the send button.
Where each reason surfaces
Probe reasons split by whether a retry can help. Permanent ones refuse boot; retryable ones boot the app and attach notReady.
Permanent boot failures โ surface via !booted.ok
reason | Source | When it fires |
|---|---|---|
unknown_engine | selectEngine โ no engine with that id, or an engine reporting a different id than it registered under | A settings engineId naming an engine that is not built |
protocol_mismatch | selectEngine โ the engine speaks a different PROTOCOL_VERSION than this build | An engine adapter upgraded out of step with the app |
storage_unavailable | bootOrFail โ a throw from createApp, caught and named | SecurityError (site data blocked) or QuotaExceededError at settingsStore.load() |
version_mismatch | selectEngine โ probe response reported a version this build cannot speak | Server upgraded incompatibly โ waiting cannot fix it |
Retryable unreadiness โ surface via booted.ok === true with booted.notReady
booted.notReady.reason | Source | When it fires |
|---|---|---|
unhealthy | selectEngine โ probe classified a 200 {"ok": false} response | Remote engine is up but reporting itself not ready (still binding, dependency down) |
http_status | selectEngine โ probe got a non-200 (including 404, 5xx, and any sub-200) | Wrong baseUrl, endpoint not routed, proxy misconfigured |
malformed | selectEngine โ probe reached something whose body is not our engine’s shape | A captive portal, a proxy error page, a different service on the port |
transport | selectEngine โ probe request or body read threw, aborted, or timed out after PROBE_TIMEOUT_MS (5s) | Engine unreachable, still binding its socket, or a hanging endpoint / black-hole proxy |
Boot-time health probe
When an EngineChoice supplies a probe, selectEngine runs it after the cheap protocol check. A retryable probe result flows into the success branch with notReady and the engine is kept; only a permanent probe result flows into failure and disposes the engine.
The probe reasons split on whether a retry can help. unhealthy, http_status, malformed, and transport all carry retryable: true โ the engine is still handed to the app so a retry costs one tap, and the app renders a warning. Only version_mismatch is permanent and refuses boot with the engine disposed.
An unreachable engine boots with a warning
Before PR #4672 a missing /health handler took the whole app down; now the app opens and points the user at Settings.
before: no /health handler โ "PraisonAI could not start: 404" (dead app, no way in)
after: no /health handler โ App opens; a warning row says the engine is not
answering yet; the user can reach Settings.How a throw becomes a typed failure
createApp calls settingsStore.load() without a guard, so a StoragePort failure propagates as an exception. The chrome is already appended by the time boot runs, so an unhandled rejection would skip both the crash screen and the listener registrations โ leaving a fully rendered app in which nothing happened. bootOrFail closes that gap.
sequenceDiagram
participant Mount as mount
participant BootOrFail as bootOrFail
participant Create as createApp
Mount->>BootOrFail: bootOrFail(options)
BootOrFail->>Create: createApp(options)
Create--xBootOrFail: throw (StoragePort failed)
BootOrFail-->>Mount: { ok: false, reason: "storage_unavailable", detail }
Mount->>Mount: renderFatal(detail)Related
The pre-boot frame and the failure notice for a bundle that cannot load.
ArchitectureBoot order and where the crash handler is installed.
Storage & SecretsWhen the StoragePort is unavailable at boot.
Failures that happen inside a turn, not at boot.
EnginesWhere unknown_engine and protocol_mismatch come from.
How the probe classifies a /health response into the reasons above.