diff --git a/src/main/hosted-sandbox/hosted-environment-provider.ts b/src/main/hosted-sandbox/hosted-environment-provider.ts
index 948856e89f5..62c498136fe 100644
--- a/src/main/hosted-sandbox/hosted-environment-provider.ts
+++ b/src/main/hosted-sandbox/hosted-environment-provider.ts
@@ -72,3 +72,8 @@ export class HostedEntitlementDeniedError extends EnvironmentProviderError {
export function isHostedEntitlementDenied(error: unknown): error is HostedEntitlementDeniedError {
return error instanceof HostedEntitlementDeniedError
}
+
+/** For a failed wake/restart/unarchive event: the plan's refusal, so the card can say why. */
+export function wakeBillingDenial(error: unknown): { billingDenial?: { reason: string | null } } {
+ return isHostedEntitlementDenied(error) ? { billingDenial: { reason: error.reason ?? null } } : {}
+}
diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts
index 2904c942919..1094d252345 100644
--- a/src/main/hosted-sandbox/hosted-sandbox-install.ts
+++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts
@@ -245,14 +245,11 @@ export function installHostedSandbox(
usageRefresh.noteStarted()
},
onStopped: (environmentId) => registry.stopEnvironment(environmentId),
- onWake: ({ workspaceId, phase, startedAt, message }) =>
+ onWake: (event) =>
workspaces.emitWakeProgress({
- workspaceId,
- phase,
- startedAt,
- message,
+ ...event,
// Later phases inherit the source recorded at `waking`.
- source: phase === 'waking' ? workspaces.takeWakeSource(workspaceId) : 'open'
+ source: event.phase === 'waking' ? workspaces.takeWakeSource(event.workspaceId) : 'open'
})
})
installed = { registry }
diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts
index 578969efb9e..48d3b3de8b5 100644
--- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts
+++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts
@@ -15,6 +15,7 @@ import { isEnvironmentProviderError } from '../environment-providers/environment
import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter'
import {
isHostedEntitlementDenied,
+ wakeBillingDenial,
type HostedEnvironmentProviderDriver,
type HostedEnvironmentReady
} from './hosted-environment-provider'
@@ -153,6 +154,7 @@ export function createHostedSandboxStepProvider(
phase: 'waking' | 'connecting' | 'ready' | 'failed'
startedAt: number
message?: string
+ billingDenial?: { reason: string | null }
}) => void
} = {}
): RecipeStepProvider {
@@ -220,7 +222,8 @@ export function createHostedSandboxStepProvider(
workspaceId,
phase: 'failed',
startedAt,
- message: error instanceof Error ? error.message : String(error)
+ message: error instanceof Error ? error.message : String(error),
+ ...wakeBillingDenial(error)
})
}
throw error
diff --git a/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts b/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts
new file mode 100644
index 00000000000..e4d8a7748f7
--- /dev/null
+++ b/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts
@@ -0,0 +1,18 @@
+// A failed wake/restart/unarchive carries the plan's refusal, so the window's card can say why.
+import { describe, expect, it } from 'vitest'
+import { HostedEntitlementDeniedError, wakeBillingDenial } from './hosted-environment-provider'
+
+describe('wakeBillingDenial', () => {
+ it('carries the billing reason of a plan refusal', () => {
+ const error = new HostedEntitlementDeniedError('refused', 'subscription_inactive')
+ expect(wakeBillingDenial(error)).toEqual({ billingDenial: { reason: 'subscription_inactive' } })
+ expect(wakeBillingDenial(new HostedEntitlementDeniedError('refused', null))).toEqual({
+ billingDenial: { reason: null }
+ })
+ })
+
+ it('adds nothing for any other failure', () => {
+ expect(wakeBillingDenial(new Error('boom'))).toEqual({})
+ expect(wakeBillingDenial('x')).toEqual({})
+ })
+})
diff --git a/src/main/hosted-sandbox/hosted-workspace-service.ts b/src/main/hosted-sandbox/hosted-workspace-service.ts
index e8c66bf5964..f4bbf2e487d 100644
--- a/src/main/hosted-sandbox/hosted-workspace-service.ts
+++ b/src/main/hosted-sandbox/hosted-workspace-service.ts
@@ -23,6 +23,7 @@ import { invalidateRuntimeEnvironmentTransport } from '../ipc/runtime-environmen
import { autoResumeAgentsAfterRollover, listRestartOfferIds } from './hosted-agent-auto-resume'
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
import { toReady } from './hosted-sandbox-driver'
+import { wakeBillingDenial } from './hosted-environment-provider'
import { noteWakeTimeline } from './cloud-wake-timeline'
import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment-call'
import { getCloudSyncService } from '../cloud-sync/cloud-sync-ipc'
@@ -255,7 +256,8 @@ export function installHostedWorkspaceService(deps: {
phase: 'failed',
source: 'restart',
startedAt,
- message: error instanceof Error ? error.message : String(error)
+ message: error instanceof Error ? error.message : String(error),
+ ...wakeBillingDenial(error)
})
deps.syncSessions()
return failure(error)
@@ -313,7 +315,8 @@ export function installHostedWorkspaceService(deps: {
phase: 'failed',
source: 'unarchive',
startedAt,
- message: error instanceof Error ? error.message : String(error)
+ message: error instanceof Error ? error.message : String(error),
+ ...wakeBillingDenial(error)
})
return failure(error)
}
diff --git a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx
index 8507aab0019..b65f05a3703 100644
--- a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx
+++ b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx
@@ -8,6 +8,12 @@ import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { wakeElapsedSeconds } from '@/lib/orca-cloud/orca-cloud-workspace-state'
import { TERMINAL_CONTENT_INSET_PX, useTerminalContentInset } from './use-terminal-content-inset'
+import { useBillingSummary } from '@/components/billing/billing-summary-store'
+import {
+ cloudWakeBillingCopy,
+ cloudWakeBillingFixLabel,
+ runCloudWakeBillingFix
+} from './cloud-wake-billing'
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
/** After this long the pill says it's slower than usual; after STUCK_S it offers recovery inline. */
@@ -101,6 +107,10 @@ export function CloudWakingOverlay({
const t = useTranslate()
const failed = wake.phase === 'failed'
const unknownServer = failed && isUnknownServerWakeFailure(wake.message)
+ const { canManageBilling } = useBillingSummary()
+ // The plan refused it: say why, offer the fix to someone who can make it, no Restart (it fails too).
+ const billing = cloudWakeBillingCopy(wake, canManageBilling, t)
+ const billingFix = billing?.fix ?? null
const now = useNow(1_000, !failed && !leaving)
const seconds = wakeElapsedSeconds(wake.startedAt, Math.max(now, wake.at))
const slow = !failed && seconds >= WAKE_SLOW_S
@@ -215,7 +225,9 @@ export function CloudWakingOverlay({
name
})}
- {wake.message && !unknownServer ? (
+ {billing ? (
+
{billing.body}
+ ) : wake.message && !unknownServer ? (
{wake.message}
) : null}
@@ -238,14 +250,24 @@ export function CloudWakingOverlay({
- {terminalStalled ? null : (
+ {terminalStalled || billing ? null : (
)}
-
) : (
diff --git a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx
index 348180dc878..b9ac003dfca 100644
--- a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx
+++ b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx
@@ -4,6 +4,8 @@ import React from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
import { CloudWakingOverlay } from './CloudWakingOverlay'
+import { recordBillingStatus } from '../billing/billing-summary-store'
+import { sampleTeam } from '../billing/plan-billing-fixtures'
const noop = (): void => {}
@@ -128,3 +130,42 @@ export const wakeFailedServerError = defineFixture({
)
})
+
+function stubBillingRole(canManageBilling: boolean): void {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture entitlement overrides only the role the card reads.
+ const result = { status: 'ok', entitlement: { ...sampleTeam, canManageBilling } } as never
+ window.api.orcaProfiles.billingStatus = () => Promise.resolve(result)
+ recordBillingStatus(result)
+}
+
+// The plan refused the wake: the card says why in plain words (never "entitlement_denied"), offers
+// plans only where buying fixes it and only to an owner or admin, and drops Restart (it fails too).
+function wakeRefused(reason: string | null, canManageBilling = true) {
+ return defineFixture({
+ width: 640,
+ setup: () => stubBillingRole(canManageBilling),
+ render: () => (
+
+
+
+ )
+ })
+}
+
+export const wakeRefusedPlanEnded = wakeRefused('subscription_inactive')
+export const wakeRefusedHoursUsed = wakeRefused('included_hours_exhausted')
+export const wakeRefusedSpendCap = wakeRefused('overage_cap_reached')
+export const wakeRefusedPayment = wakeRefused('payment_past_due')
+export const wakeRefusedTeamLimit = wakeRefused('concurrency_limit')
+export const wakeRefusedPlanEndedMember = wakeRefused('subscription_inactive', false)
diff --git a/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts b/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts
new file mode 100644
index 00000000000..a39cadb6d01
--- /dev/null
+++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts
@@ -0,0 +1,65 @@
+// A wake the plan refused says why in the create sheet's words, offers the fix only to an owner or
+// admin (a member reads who to ask), and never shows the control plane's code.
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
+
+import { translate } from '@/i18n/i18n'
+import { cloudWakeBillingCopy } from './cloud-wake-billing'
+import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
+
+const failed = (reason: string | null, over: Partial = {}) =>
+ ({
+ workspaceId: 'ws',
+ phase: 'failed',
+ source: 'open',
+ startedAt: 1,
+ at: 2,
+ message: 'entitlement_denied',
+ billingDenial: { reason },
+ ...over
+ }) satisfies CloudWakeProgressEvent
+
+describe('cloudWakeBillingCopy', () => {
+ it('is null for a failure the plan did not cause, and while still waking', () => {
+ expect(
+ cloudWakeBillingCopy({ ...failed(null), billingDenial: undefined }, true, translate)
+ ).toBeNull()
+ expect(
+ cloudWakeBillingCopy(failed('subscription_inactive', { phase: 'waking' }), true, translate)
+ ).toBeNull()
+ })
+
+ it('an owner or admin gets the fix that applies', () => {
+ const fix = (reason: string) => cloudWakeBillingCopy(failed(reason), true, translate)?.fix
+ expect(fix('subscription_inactive')).toBe('seePlans')
+ expect(fix('included_hours_exhausted')).toBe('seePlans')
+ expect(fix('overage_cap_reached')).toBe('raiseSpendCap')
+ expect(fix('payment_past_due')).toBe('updatePayment')
+ })
+
+ it('a member gets no fix button, and the copy says who to ask', () => {
+ const copy = cloudWakeBillingCopy(failed('subscription_inactive'), false, translate)
+ expect(copy?.fix).toBeNull()
+ expect(copy?.body).toMatch(/Ask an owner or admin/)
+ // An owner is never told to ask an owner.
+ expect(
+ cloudWakeBillingCopy(failed('subscription_inactive'), true, translate)?.body
+ ).not.toMatch(/Ask an owner/)
+ })
+
+ it('running limits and unknown reasons use the shared copy, with no billing fix', () => {
+ expect(cloudWakeBillingCopy(failed('concurrency_limit'), true, translate)?.fix).toBeNull()
+ expect(cloudWakeBillingCopy(failed('something_new'), true, translate)?.fix).toBeNull()
+ })
+
+ it('never shows the raw code', () => {
+ for (const reason of [null, 'subscription_inactive', 'concurrency_limit', 'something_new']) {
+ for (const owner of [true, false]) {
+ const copy = cloudWakeBillingCopy(failed(reason), owner, translate)
+ expect(copy?.body).toBeTruthy()
+ expect(copy?.body).not.toMatch(/entitlement|_/)
+ }
+ }
+ })
+})
diff --git a/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts
new file mode 100644
index 00000000000..70f793b8fd4
--- /dev/null
+++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts
@@ -0,0 +1,82 @@
+// A wake, restart or unarchive the plan refused: the failure card says why in the same words as the
+// create sheet's billing blocker, and offers the one fix that applies to someone who can make it
+// (owner or admin). A member reads who to ask. Never the control plane's code.
+import type { Translate } from '@/i18n/use-translate'
+import { useAppStore } from '@/store'
+import { describeError } from '@/components/cloud-workspaces/cloud-workspace-errors'
+import {
+ blockerCopy,
+ type BillingBlockKind
+} from '@/components/worktree-creation/VmBuildBillingBlocker'
+import { openPlansPage } from '@/components/billing/plans/plans-page-store'
+import { ORCA_BILLING_OVERAGE_SECTION_ID } from '@/components/new-workspace/orca-cloud-run-target-access'
+import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
+import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
+
+export type CloudWakeBillingFix = 'seePlans' | 'raiseSpendCap' | 'updatePayment'
+export type CloudWakeBillingCopy = { body: string; fix: CloudWakeBillingFix | null }
+
+const BLOCK_KIND: Partial, BillingBlockKind>> =
+ { plan: 'plan', hours: 'hoursUsed', spendCap: 'spendCap', payment: 'payment' }
+
+const FIX: Record = {
+ plan: 'seePlans',
+ hoursUsed: 'seePlans',
+ spendCap: 'raiseSpendCap',
+ payment: 'updatePayment'
+}
+
+export function cloudWakeBillingCopy(
+ wake: CloudWakeProgressEvent,
+ canManageBilling: boolean,
+ t: Translate
+): CloudWakeBillingCopy | null {
+ if (wake.phase !== 'failed' || !wake.billingDenial) {
+ return null
+ }
+ const reason = wake.billingDenial.reason ?? undefined
+ const kind = BLOCK_KIND[classifyEntitlementDenial(reason)]
+ if (!kind) {
+ // Running limits and anything unknown: the shared workspace copy (it says what to do).
+ return { body: describeError('entitlement_denied', reason, t), fix: null }
+ }
+ const copy = blockerCopy(kind, t)
+ // The sheet's payment line is about new sandboxes; this one already exists.
+ const body =
+ kind === 'payment'
+ ? t('orcaCloudWorkspace.overlay.billingPayment', 'Your team’s last payment failed.')
+ : copy.body
+ return canManageBilling ? { body, fix: FIX[kind] } : { body: `${body} ${copy.ask}`, fix: null }
+}
+
+export function cloudWakeBillingFixLabel(fix: CloudWakeBillingFix, t: Translate): string {
+ switch (fix) {
+ case 'seePlans':
+ return t('orcaCloudWorkspace.error.seePlans', 'See plans')
+ case 'raiseSpendCap':
+ return t(
+ 'auto.components.worktree.creation.VmBuildFailureCard.raiseSpendCap',
+ 'Raise spend cap'
+ )
+ case 'updatePayment':
+ return t(
+ 'auto.components.worktree.creation.VmBuildFailureCard.updatePayment',
+ 'Update payment method'
+ )
+ }
+}
+
+export function runCloudWakeBillingFix(fix: CloudWakeBillingFix): void {
+ if (fix === 'seePlans') {
+ openPlansPage('sandbox')
+ return
+ }
+ const store = useAppStore.getState()
+ store.openSettingsTarget({
+ pane: 'cloud-billing',
+ repoId: null,
+ // The spend cap lives on the overage card; the payment notice tops the section already.
+ ...(fix === 'raiseSpendCap' ? { sectionId: ORCA_BILLING_OVERAGE_SECTION_ID } : {})
+ })
+ store.openSettingsPage()
+}
diff --git a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx
index e77f849fc96..b7eb6b6f8e7 100644
--- a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx
+++ b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx
@@ -22,7 +22,8 @@ export type BillingBlockerPhase =
/** A fix in the browser (portal): re-check on return, or with Check again. */
| { kind: 'browser'; checking: boolean; stillBlocked: boolean }
-function blockerCopy(
+/** The blocker's words per refusal; also the waking card's, so a refused wake says the same. */
+export function blockerCopy(
kind: BillingBlockKind,
t: Translate
): { title: string; body: string; ask: string } {
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 368ad09f13f..27158210b42 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -27699,7 +27699,8 @@
"pendingKeys_one": "{{count}} key waiting · it goes to the terminal when it’s up",
"pendingKeys_other": "{{count}} keys waiting · they go to the terminal when it’s up",
"clearPendingKeys": "Discard waiting keys",
- "unarchivingNow": "Unarchiving…"
+ "unarchivingNow": "Unarchiving…",
+ "billingPayment": "Your team’s last payment failed."
},
"announce": {
"restarting": "Restarting {{name}}.",
diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts
index 1128e78feee..6222ac3bc82 100644
--- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts
+++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts
@@ -85,6 +85,24 @@ describe('waking a paired workspace', () => {
expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('failed')
})
+ it('a plan refusal marks the failed wake as billing, so the card says why (never the code)', async () => {
+ wake.mockResolvedValueOnce({
+ ok: false,
+ error: 'entitlement_denied',
+ reason: 'included_hours_exhausted'
+ })
+ await wakePairedCloudWorkspace(ROW)
+ expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toEqual({
+ reason: 'included_hours_exhausted'
+ })
+ })
+
+ it('any other refusal is not marked as billing', async () => {
+ wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' })
+ await wakePairedCloudWorkspace(ROW)
+ expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toBeUndefined()
+ })
+
it('a predictive (quiet) wake that is refused leaves no failed card behind', async () => {
wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' })
expect((await wakePairedCloudWorkspace(ROW, { quiet: true })).ok).toBe(false)
diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts
index 013e563227f..0e08a985a04 100644
--- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts
+++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts
@@ -48,7 +48,10 @@ export async function wakePairedCloudWorkspace(
}
const startedAt = Date.now()
const source = options.source ?? 'open'
- const report = (phase: 'waking' | 'ready' | 'failed', message?: string): void => {
+ const report = (
+ phase: 'waking' | 'ready' | 'failed',
+ failure?: { message: string; reason?: string }
+ ): void => {
if (!options.quiet) {
noteCloudWakeProgress({
workspaceId,
@@ -56,7 +59,11 @@ export async function wakePairedCloudWorkspace(
source,
startedAt,
at: Date.now(),
- ...(message ? { message } : {})
+ ...(failure ? { message: failure.message } : {}),
+ // The plan refused it: the card says why (and offers plans), never the raw code.
+ ...(failure?.message === 'entitlement_denied'
+ ? { billingDenial: { reason: failure.reason ?? null } }
+ : {})
})
}
}
@@ -69,7 +76,7 @@ export async function wakePairedCloudWorkspace(
if (result.ok) {
report('ready')
} else {
- report('failed', result.error)
+ report('failed', { message: result.error, ...(result.reason ? { reason: result.reason } : {}) })
}
await refreshCloudWorkspaceStatus(workspaceId)
return result.ok
diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts
index 70b49baf94d..ec1915d219d 100644
--- a/src/shared/hosted-sandbox/cloud-workspace-api.ts
+++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts
@@ -44,6 +44,8 @@ export type CloudWakeProgressEvent = {
startedAt: number
at: number
message?: string
+ /** The plan refused it (billing's deny reason, null when unsaid): the card says why, not the code. */
+ billingDenial?: { reason: string | null }
}
export type CloudOomEvent = {