From fc7006c5e39e6d1a17786dc7703cb53733b58c6d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Apr 2026 19:42:08 -0500 Subject: [PATCH 1/4] fix: verify published cli with okcode bin --- .github/workflows/release.yml | 4 ++-- docs/releases/v0.26.0/rollout-checklist.md | 2 +- scripts/prepare-release.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9a7b7f..50f64938 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,8 +342,8 @@ jobs: - name: Verify published CLI run: | - npx --yes okcodes@${{ needs.preflight.outputs.version }} --version - npx --yes okcodes@${{ needs.preflight.outputs.version }} --help >/dev/null + npm exec --yes --package okcodes@${{ needs.preflight.outputs.version }} -- okcode --version + npm exec --yes --package okcodes@${{ needs.preflight.outputs.version }} -- okcode --help >/dev/null release: name: Publish GitHub Release diff --git a/docs/releases/v0.26.0/rollout-checklist.md b/docs/releases/v0.26.0/rollout-checklist.md index e30c1cc8..8ad6f948 100644 --- a/docs/releases/v0.26.0/rollout-checklist.md +++ b/docs/releases/v0.26.0/rollout-checklist.md @@ -52,7 +52,7 @@ Step-by-step playbook for the v0.26.0 release. Each phase must complete before a ## Phase 2: Post-release verification -- [ ] `npx --yes okcodes@0.26.0 --version` returns `0.26.0`. +- [ ] `npm exec --yes --package okcodes@0.26.0 -- okcode --version` returns `0.26.0`. - [ ] macOS installer launches and passes Gatekeeper. - [ ] Linux AppImage launches. - [ ] Windows installer installs and launches. diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 72fd8d56..14ad772c 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -395,7 +395,7 @@ Step-by-step playbook for the v${version} release. Each phase must complete befo ## Phase 2: Post-release verification -- [ ] \`npx --yes okcodes@${version} --version\` returns \`${version}\`. +- [ ] \`npm exec --yes --package okcodes@${version} -- okcode --version\` returns \`${version}\`. - [ ] macOS installer launches and passes Gatekeeper. - [ ] Linux AppImage launches. - [ ] Windows installer installs and launches. From e38809828d0af5b06612e9ffd1a1aea9f1baf5ee Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Apr 2026 20:31:16 -0500 Subject: [PATCH 2/4] fix: make npm publish rerun-safe --- apps/server/scripts/cli.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index abc83c07..c49ececc 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -185,6 +185,8 @@ const publishCmd = Command.make( const serverDir = path.join(repoRoot, "apps/server"); const packageJsonPath = path.join(serverDir, "package.json"); const backupPath = `${packageJsonPath}.bak`; + const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); + const publishTarget = `${serverPackageJson.name}@${version}`; // Assert build assets exist for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { @@ -201,7 +203,6 @@ const publishCmd = Command.make( Effect.gen(function* () { // Resolve catalog dependencies before any file mutations. If this throws, // acquire fails and no release hook runs, so filesystem must still be untouched. - const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const pkg = { name: serverPackageJson.name, repository: serverPackageJson.repository, @@ -230,6 +231,25 @@ const publishCmd = Command.make( // Use: npm publish () => Effect.gen(function* () { + const versionExists = yield* runCommand( + ChildProcess.make("npm", ["view", publishTarget, "version"], { + cwd: serverDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + shell: process.platform === "win32", + }), + ).pipe( + Effect.as(true), + Effect.catchTag("CliError", () => Effect.succeed(false)), + ); + + if (versionExists) { + yield* Effect.logWarning( + `[cli] ${publishTarget} is already published; skipping npm publish.`, + ); + return; + } + const args = ["publish", "--access", config.access, "--tag", config.tag]; if (config.provenance) args.push("--provenance"); if (config.dryRun) args.push("--dry-run"); From 1cbbca6d4d951c2b4a4da5aee07a3caa080fa335 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Apr 2026 20:54:25 -0500 Subject: [PATCH 3/4] fix: publish cli from cjs bin --- apps/server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 490e7b15..4af2ce71 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "directory": "apps/server" }, "bin": { - "okcode": "./dist/index.mjs" + "okcode": "./dist/index.cjs" }, "files": [ "dist" From b7bdeff0d4e0eaac9e82824cfce105e260002682 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Apr 2026 21:40:30 -0500 Subject: [PATCH 4/4] Add out-of-memory session recovery --- apps/web/src/components/ChatView.browser.tsx | 72 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 12 ++++ .../chat/ErrorNotificationBar.test.tsx | 29 +++++++- .../components/chat/ErrorNotificationBar.tsx | 44 ++++++++++++ .../src/components/chat/threadError.test.ts | 13 ++++ apps/web/src/components/chat/threadError.ts | 32 +++++++++ 6 files changed, 201 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e6147f1f..8f462a4a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -291,6 +291,34 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { }; } +function withThreadSessionError( + snapshot: OrchestrationReadModel, + input: { + status: OrchestrationSessionStatus; + lastError: string; + }, +): OrchestrationReadModel { + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID && thread.session + ? { + ...thread, + session: { + threadId: thread.session.threadId, + providerName: thread.session.providerName, + runtimeMode: thread.session.runtimeMode, + activeTurnId: thread.session.activeTurnId, + status: input.status, + lastError: input.lastError, + updatedAt: NOW_ISO, + }, + } + : thread, + ), + }; +} + function addThreadToSnapshot( snapshot: OrchestrationReadModel, threadId: ThreadId, @@ -1634,6 +1662,50 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("resets the provider session from the error banner after an out-of-memory failure", async () => { + wsRequests.length = 0; + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withThreadSessionError( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-oom-reset" as MessageId, + targetText: "oom reset target", + }), + { + status: "error", + lastError: + "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + }, + ), + }); + + try { + const recoverButton = await waitForElement( + () => + document.querySelector( + 'button[aria-label="Reset session after out-of-memory failure"]', + ), + "Unable to find out-of-memory recovery button.", + ); + + recoverButton.click(); + + await vi.waitFor( + () => + wsRequests.some( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.session.stop" && + request.threadId === THREAD_ID, + ), + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 17bf1656..af2e1f2f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3880,6 +3880,17 @@ export default function ChatView({ }); }; + const onRecoverFromOutOfMemory = async () => { + const api = readNativeApi(); + if (!api || !activeThread || isRemoteActionBlocked) return; + await api.orchestration.dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + }); + }; + const onClearQueue = useCallback(() => { setOptimisticUserMessages((existing) => { for (const msg of existing) { @@ -4955,6 +4966,7 @@ export default function ChatView({ showNotificationDetails={settings.showNotificationDetails} includeDiagnosticsTipsInCopy={settings.includeDiagnosticsTipsInCopy} onDismissThreadError={() => setThreadError(activeThread.id, null)} + onRecoverFromOutOfMemory={() => void onRecoverFromOutOfMemory()} providerStatus={activeProviderStatus} transportState={transportState} isMobileCompanion={isMobileCompanion} diff --git a/apps/web/src/components/chat/ErrorNotificationBar.test.tsx b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx index b23bc183..6b880c16 100644 --- a/apps/web/src/components/chat/ErrorNotificationBar.test.tsx +++ b/apps/web/src/components/chat/ErrorNotificationBar.test.tsx @@ -24,7 +24,8 @@ const THREAD_ERROR = function renderBar( overrides: Partial> = {}, ): ReactElement { - const { onDismissThreadError, transportState, ...restOverrides } = overrides; + const { onDismissThreadError, onRecoverFromOutOfMemory, transportState, ...restOverrides } = + overrides; return ( ); @@ -85,4 +87,29 @@ describe("ErrorNotificationBar", () => { expect(markup).toContain("Worktree thread could not start"); expect(markup).toContain("Base branch 'main' does not resolve to a commit yet."); }); + + it("shows an out-of-memory recovery action when the thread error is recoverable", async () => { + const onRecoverFromOutOfMemory = vi.fn(); + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = create( + renderBar({ + threadError: + "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + onRecoverFromOutOfMemory, + }), + ); + }); + + const root = renderer!.root; + const recoverButton = root.findByProps({ + "aria-label": "Reset session after out-of-memory failure", + }); + + await act(async () => { + recoverButton.props.onClick(); + }); + + expect(onRecoverFromOutOfMemory).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/components/chat/ErrorNotificationBar.tsx b/apps/web/src/components/chat/ErrorNotificationBar.tsx index f8b9298d..dfedf6aa 100644 --- a/apps/web/src/components/chat/ErrorNotificationBar.tsx +++ b/apps/web/src/components/chat/ErrorNotificationBar.tsx @@ -13,6 +13,7 @@ import { buildThreadErrorDiagnosticsCopy, humanizeThreadError, isAuthenticationThreadError, + isOutOfMemoryThreadError, } from "./threadError"; import { getProviderStatusHeading, @@ -33,6 +34,8 @@ interface ErrorNotificationBarProps { includeDiagnosticsTipsInCopy?: boolean; /** Dismiss the thread error */ onDismissThreadError?: () => void; + /** Reset a provider session after an OOM failure */ + onRecoverFromOutOfMemory?: () => void; /** Provider health status */ providerStatus: ServerProviderStatus | null; /** Companion transport state (only relevant for mobile companion) */ @@ -49,6 +52,9 @@ interface NotificationItem { description: string; detailsText?: string | null; diagnosticsCopyText?: string | null; + actionLabel?: string; + actionAriaLabel?: string; + onAction?: () => void; severity: "error" | "warning" | "info"; dismissible: boolean; onDismiss?: () => void; @@ -60,6 +66,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ showNotificationDetails = false, includeDiagnosticsTipsInCopy = false, onDismissThreadError, + onRecoverFromOutOfMemory, providerStatus, transportState, isMobileCompanion, @@ -129,6 +136,8 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ if (threadError) { if (showAuthFailuresAsErrors || !isAuthenticationThreadError(threadError)) { const presentation = humanizeThreadError(threadError); + const showOutOfMemoryRecovery = + isOutOfMemoryThreadError(threadError) && onRecoverFromOutOfMemory !== undefined; items.push({ id: "thread-error", kind: "thread-error", @@ -139,6 +148,13 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ diagnosticsCopyText: buildThreadErrorDiagnosticsCopy(threadError, { includeTips: includeDiagnosticsTipsInCopy, }), + ...(showOutOfMemoryRecovery + ? { + actionLabel: "Reset session", + actionAriaLabel: "Reset session after out-of-memory failure", + onAction: onRecoverFromOutOfMemory, + } + : {}), severity: "error", dismissible: !!onDismissThreadError, ...(onDismissThreadError ? { onDismiss: onDismissThreadError } : {}), @@ -152,6 +168,7 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ showAuthFailuresAsErrors, includeDiagnosticsTipsInCopy, onDismissThreadError, + onRecoverFromOutOfMemory, providerStatus, transportState, isMobileCompanion, @@ -217,6 +234,9 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({ if (visibleNotifications.length === 0) return null; const primary = visibleNotifications[0]!; + const actionNotification = visibleNotifications.find( + (notification) => notification.onAction && notification.actionLabel, + ); const PrimaryIcon = primary.icon; const count = visibleNotifications.length; const countLabel = count === 1 ? "1 notification" : `${count} notifications`; @@ -257,6 +277,18 @@ export const ErrorNotificationBar = memo(function ErrorNotificationBar({
+ {actionNotification?.onAction && actionNotification.actionLabel ? ( + + ) : null} + ) : null} {notif.kind === "thread-error" && notif.diagnosticsCopyText ? ( { expect(isAuthenticationThreadError("Provider crashed while starting.")).toBe(false); }); + it("detects out-of-memory failures", () => { + expect( + isOutOfMemoryThreadError( + "Provider crashed: FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory", + ), + ).toBe(true); + expect( + isOutOfMemoryThreadError("Process exited: memory limit exceeded while streaming turn"), + ).toBe(true); + expect(isOutOfMemoryThreadError("Provider crashed while starting.")).toBe(false); + }); + it("builds redacted diagnostics copy without optional tips by default", () => { expect( buildThreadErrorDiagnosticsCopy( diff --git a/apps/web/src/components/chat/threadError.ts b/apps/web/src/components/chat/threadError.ts index c1f61778..ddeb52ed 100644 --- a/apps/web/src/components/chat/threadError.ts +++ b/apps/web/src/components/chat/threadError.ts @@ -26,6 +26,13 @@ const AUTH_FAILURE_PATTERNS = [ "could not resolve authentication method", "authentication required", ] as const; +const OUT_OF_MEMORY_PATTERNS = [ + "out of memory", + "heap out of memory", + "reached heap limit", + "memory limit exceeded", + "allocation failed - javascript heap", +] as const; function extractWorktreeDetail(error: string): string | null { if (!error.startsWith(WORKTREE_COMMAND_PREFIX)) { @@ -62,6 +69,12 @@ function buildTroubleshootingTips(error: string, presentation: ThreadErrorPresen ); } + if (isOutOfMemoryThreadError(error)) { + tips.push( + "Reset the provider session, then retry with a smaller prompt, fewer attachments, or less terminal context.", + ); + } + if (presentation.title === "Worktree thread could not start") { tips.push( "Create the first commit or switch to a base branch that resolves to a commit before starting a worktree thread.", @@ -81,6 +94,16 @@ export function isAuthenticationThreadError(error: string | null | undefined): b return AUTH_FAILURE_PATTERNS.some((pattern) => lower.includes(pattern)); } +export function isOutOfMemoryThreadError(error: string | null | undefined): boolean { + const trimmed = error?.trim(); + if (!trimmed) { + return false; + } + + const lower = trimmed.toLowerCase(); + return OUT_OF_MEMORY_PATTERNS.some((pattern) => lower.includes(pattern)); +} + export function humanizeThreadError(error: string): ThreadErrorPresentation { const trimmed = redactSensitiveText(error).trim(); const worktreeDetail = extractWorktreeDetail(trimmed); @@ -92,6 +115,15 @@ export function humanizeThreadError(error: string): ThreadErrorPresentation { }; } + if (isOutOfMemoryThreadError(trimmed)) { + return { + title: "Session ran out of memory", + description: + "The provider session ran out of memory. Reset the session, then resend the prompt.", + technicalDetails: trimmed, + }; + } + return { title: null, description: trimmed.length > 0 ? trimmed : "An unexpected error occurred.",