+ {billingFailure && (
+
+ {!billingFailure && statusIndicator?.type === 'error' && (
+
+ )}
{(sessionConfig?.repository ||
(contextUsage !== undefined && contextWindow !== undefined)) && (
diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
index 7b59cbec0a..170f4bfb23 100644
--- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
+++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
@@ -70,6 +70,11 @@ import {
} from '@/components/cloud-agent-next/utils/git-utils';
import type { AgentMode } from './types';
import { formatSessionError } from '@kilocode/cloud-agent-sdk';
+import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk';
+import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk';
+import { CloudAgentBillingError } from './CloudAgentBillingError';
+import { billingPayerPresentation } from './billing-payer-presentation';
+import type { OrganizationRole } from '@/lib/organizations/organization-types';
import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id';
import { useCloudAgentAttachmentUpload } from '@/hooks/useCloudAgentAttachmentUpload';
import { AttachmentPreviewStrip } from './AttachmentPreviewStrip';
@@ -114,7 +119,10 @@ type Repository = {
};
type NewSessionPanelProps = {
+ currentUserId: string;
organizationId?: string;
+ organizationName?: string;
+ organizationRole?: OrganizationRole;
isDevcontainerAvailable: boolean;
};
@@ -125,7 +133,13 @@ type ContextualTipProps = {
onDismiss: () => void;
};
-export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: NewSessionPanelProps) {
+export function NewSessionPanel({
+ currentUserId,
+ organizationId,
+ organizationName,
+ organizationRole,
+ isDevcontainerAvailable,
+}: NewSessionPanelProps) {
const router = useRouter();
const trpc = useTRPC();
const trpcClient = useRawTRPCClient();
@@ -202,6 +216,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
const [isRepoUserSelected, setIsRepoUserSelected] = useState(false);
const [showRepositoryRequiredMessage, setShowRepositoryRequiredMessage] = useState(false);
const [isPreparing, setIsPreparing] = useState(false);
+ const [billingFailure, setBillingFailure] = useState
(null);
const [attachmentMessageUuid, setAttachmentMessageUuid] = useState(() => uuidv4());
// Repo profile bindings are only keyed by GitHub/GitLab today.
const profileBindingPlatform: Exclude | undefined =
@@ -1087,11 +1102,14 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud';
router.push(`${basePath}/chat?sessionId=${result.kiloSessionId}`);
+ setBillingFailure(null);
} catch (error) {
+ const failure = parseCustomerBillingFailure(error);
+ setBillingFailure(failure);
console.error('Failed to prepare session:', error);
- toast.error('Failed to create session', {
- description: formatSessionError(error),
- });
+ if (!failure) {
+ toast.error('Failed to create session', { description: formatSessionError(error) });
+ }
} finally {
setIsPreparing(false);
}
@@ -1218,7 +1236,19 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
- {/* Insufficient balance banner */}
+ {billingFailure && (
+
+ )}
+ {/* Organization members intentionally see the shared balance: the existing eligibility API returns it to members. */}
{hasInsufficientBalance && eligibilityData && !hasLimitedAccess && (
@@ -81,10 +84,43 @@ export function SessionInfoDialog({
- Total Cost
+ Token Usage
- ${costInDollars.toFixed(4)}
+ ${tokenUsageDollars.toFixed(4)}
+
+
+
+
+ Compute
+
+
+ {computeStatus?.phase === 'unavailable' || !computeStatus ? (
+
Compute status unavailable
+ ) : computeStatus.estimatedHourlyRateMicrodollars === null ? (
+
Compute pricing unavailable
+ ) : (
+ <>
+
+ Est. ${(computeStatus.estimatedHourlyRateMicrodollars / 1_000_000).toFixed(2)}{' '}
+ / hour
+
+
+ Billed only while the sandbox runs.
+
+ {computeStatus.billingMode === 'shadow' &&
Not currently charged
}
+
+ {computeStatus.attribution === 'payer_shared'
+ ? 'The estimate may include other sessions using this shared sandbox.'
+ : 'The estimate is based on this sandbox’s runtime.'}
+
+ {computeStatus.phase === 'stopping' || computeStatus.phase === 'settling' ? (
+
+ Saving and stopping. Final cost is confirmed after it stops.
+
+ ) : null}
+ >
+ )}
diff --git a/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts
new file mode 100644
index 0000000000..533e037239
--- /dev/null
+++ b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from '@jest/globals';
+import { billingPayerPresentation } from './billing-payer-presentation';
+
+const failure = {
+ code: 'INSUFFICIENT_CREDITS' as const,
+ payer: { type: 'org' as const, id: 'org-1' },
+ retryable: false,
+};
+
+describe('billingPayerPresentation', () => {
+ it('uses the actual organization name and billing-manager action', () => {
+ expect(
+ billingPayerPresentation(failure, {
+ organization: { id: 'org-1', name: 'Long Organization Name', role: 'billing_manager' },
+ })
+ ).toEqual({
+ payerName: 'Long Organization Name',
+ action: { href: '/organizations/org-1', label: 'Add organization credits' },
+ });
+ });
+
+ it('gives ordinary members the billing-details action and guidance', () => {
+ expect(
+ billingPayerPresentation(failure, {
+ organization: { id: 'org-1', name: 'Acme', role: 'member' },
+ })
+ ).toEqual({
+ payerName: 'Acme',
+ action: {
+ href: '/organizations/org-1/payment-details',
+ label: 'View organization billing',
+ memberGuidance: true,
+ },
+ });
+ });
+
+ it('does not expose an action when the payer differs from the authorized surface', () => {
+ expect(
+ billingPayerPresentation(failure, {
+ organization: { id: 'org-2', name: 'Other', role: 'owner' },
+ })
+ ).toEqual({ payerName: 'This organization' });
+ });
+
+ it('uses the personal credits action only on the personal surface', () => {
+ expect(
+ billingPayerPresentation(
+ { ...failure, payer: { type: 'user', id: 'user-1' } },
+ { currentUserId: 'user-1' }
+ )
+ ).toEqual({ payerName: 'Your account', action: { href: '/credits', label: 'Add credits' } });
+ });
+
+ it('does not expose a personal payment action for another payer', () => {
+ expect(
+ billingPayerPresentation(
+ { ...failure, payer: { type: 'user', id: 'other-user' } },
+ { currentUserId: 'user-1' }
+ )
+ ).toEqual({ payerName: 'Your account' });
+ });
+
+ it.each(['COMPUTE_STOPPING', 'BILLING_UNAVAILABLE'] as const)(
+ 'does not offer credit recovery for %s',
+ code => {
+ expect(
+ billingPayerPresentation(
+ { ...failure, code },
+ { organization: { id: 'org-1', name: 'Acme', role: 'billing_manager' } }
+ )
+ ).toEqual({ payerName: 'Acme' });
+ expect(
+ billingPayerPresentation(
+ { ...failure, code, payer: { type: 'user', id: 'user-1' } },
+ { currentUserId: 'user-1' }
+ )
+ ).toEqual({ payerName: 'Your account' });
+ }
+ );
+});
diff --git a/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts
new file mode 100644
index 0000000000..76efde4928
--- /dev/null
+++ b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts
@@ -0,0 +1,46 @@
+import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations';
+import type { OrganizationRole } from '@/lib/organizations/organization-types';
+import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk';
+
+export type BillingPayerPresentation = {
+ payerName: string;
+ action?: { href: string; label: string; memberGuidance?: boolean };
+};
+
+export function billingPayerPresentation(
+ failure: CustomerBillingFailure,
+ surface: {
+ currentUserId?: string;
+ organization?: { id: string; name: string; role: OrganizationRole };
+ }
+): BillingPayerPresentation {
+ const canRecoverWithCredits = failure.code === 'INSUFFICIENT_CREDITS';
+ if (
+ failure.payer.type === 'user' &&
+ !surface.organization &&
+ failure.payer.id === surface.currentUserId
+ ) {
+ return {
+ payerName: 'Your account',
+ ...(canRecoverWithCredits ? { action: { href: '/credits', label: 'Add credits' } } : {}),
+ };
+ }
+ const organization = surface.organization;
+ if (!organization || failure.payer.type !== 'org' || failure.payer.id !== organization.id) {
+ return { payerName: failure.payer.type === 'org' ? 'This organization' : 'Your account' };
+ }
+ return {
+ payerName: organization.name,
+ ...(canRecoverWithCredits
+ ? {
+ action: canManageOrganizationBilling(organization.role)
+ ? { href: `/organizations/${organization.id}`, label: 'Add organization credits' }
+ : {
+ href: `/organizations/${organization.id}/payment-details`,
+ label: 'View organization billing',
+ memberGuidance: true,
+ },
+ }
+ : {}),
+ };
+}
diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts
index 6475870789..a869ca5f45 100644
--- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts
+++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts
@@ -8,6 +8,7 @@ import type { Images } from '@/lib/images-schema';
import { getEnvVariable } from '@/lib/dotenvx';
import { captureException } from '@sentry/nextjs';
import { INTERNAL_API_SECRET } from '@/lib/config.server';
+import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk';
import type { SendMessagePayload } from './types.js';
export type { SendMessagePayload } from './types.js';
@@ -374,6 +375,23 @@ export type HealthOutput = {
version: string;
};
+export type ComputeBillingStatus = {
+ payer: { type: 'user' | 'org'; id: string };
+ attribution: 'payer_shared' | 'session';
+ phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable';
+ estimatedHourlyRateMicrodollars: number | null;
+ estimatedIntervalAmountMicrodollars: number | null;
+ billingMode: 'shadow' | 'paid' | null;
+ interval: {
+ id: string;
+ startedAt: string;
+ lastSeenAt: string;
+ stoppedAt: string | null;
+ confirmedSeconds: number;
+ settledBillableSeconds: number;
+ } | null;
+};
+
/**
* Custom error class for payment-related errors from cloud-agent.
*/
@@ -387,10 +405,32 @@ export class InsufficientCreditsError extends Error {
}
}
+export class CloudAgentBillingError extends Error {
+ constructor(
+ readonly billingFailure: NonNullable>,
+ readonly httpStatus: 402 | 409 | 503
+ ) {
+ super('Cloud Agent compute billing request was not accepted');
+ this.name = 'CloudAgentBillingError';
+ }
+}
+
/**
* Helper to re-throw InsufficientCreditsError as TRPCError with PAYMENT_REQUIRED code.
*/
export function rethrowAsPaymentRequired(error: unknown): never {
+ if (error instanceof CloudAgentBillingError) {
+ throw new TRPCError({
+ code:
+ error.httpStatus === 402
+ ? 'PAYMENT_REQUIRED'
+ : error.httpStatus === 409
+ ? 'CONFLICT'
+ : 'SERVICE_UNAVAILABLE',
+ message: error.message,
+ cause: { billingFailure: error.billingFailure },
+ });
+ }
if (error instanceof InsufficientCreditsError) {
throw new TRPCError({
code: 'PAYMENT_REQUIRED',
@@ -404,6 +444,7 @@ export function rethrowAsPaymentRequired(error: unknown): never {
* Check if an error indicates insufficient credits (402 Payment Required).
*/
function isInsufficientCreditsError(err: unknown): boolean {
+ if (parseCustomerBillingFailure(err)) return false;
if (err instanceof TRPCClientError) {
const httpStatus = err.data?.httpStatus || err.shape?.data?.httpStatus;
if (httpStatus === 402) {
@@ -420,6 +461,19 @@ function isInsufficientCreditsError(err: unknown): boolean {
return false;
}
+function preserveBillingFailure(error: unknown): void {
+ const billingFailure = parseCustomerBillingFailure(error);
+ if (!billingFailure) return;
+ const status =
+ error instanceof TRPCClientError
+ ? (error.data?.httpStatus ?? error.shape?.data?.httpStatus)
+ : undefined;
+ throw new CloudAgentBillingError(
+ billingFailure,
+ status === 402 || status === 409 || status === 503 ? status : 503
+ );
+}
+
function normalizeCloudAgentProtocolError(error: unknown): unknown {
if (!(error instanceof Error)) {
return error;
@@ -454,6 +508,9 @@ type CloudAgentNextTRPCClient = {
getSession: {
query: (input: GetSessionInput) => Promise;
};
+ getComputeBillingStatus: {
+ query: (input: GetSessionInput) => Promise;
+ };
prepareSession: {
mutate: (input: PrepareSessionInput) => Promise;
};
@@ -625,6 +682,10 @@ export class CloudAgentNextClient {
}
}
+ async getComputeBillingStatus(cloudAgentSessionId: string): Promise {
+ return await this.client.getComputeBillingStatus.query({ cloudAgentSessionId });
+ }
+
/**
* Prepare a new cloud agent session.
*/
@@ -647,6 +708,7 @@ export class CloudAgentNextClient {
return result;
} catch (error) {
const normalizedError = normalizeCloudAgentProtocolError(error);
+ preserveBillingFailure(normalizedError);
console.log('[CloudAgentNextClient.prepareSession] Request failed', {
elapsed: Date.now() - startTime,
@@ -699,6 +761,7 @@ export class CloudAgentNextClient {
return await this.client.initiateFromKilocodeSessionV2.mutate(input);
} catch (error) {
const normalizedError = normalizeCloudAgentProtocolError(error);
+ preserveBillingFailure(normalizedError);
// Check for insufficient credits error
if (isInsufficientCreditsError(normalizedError)) {
@@ -727,6 +790,7 @@ export class CloudAgentNextClient {
return await this.client.sendMessageV2.mutate(input);
} catch (error) {
const normalizedError = normalizeCloudAgentProtocolError(error);
+ preserveBillingFailure(normalizedError);
// Check for insufficient credits error
if (isInsufficientCreditsError(normalizedError)) {
diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts
index fd7b09d8cf..ff2b732e1d 100644
--- a/apps/web/src/lib/openapi/trpc-openapi.test.ts
+++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts
@@ -24,6 +24,29 @@ const verificationRouter = t.router({
cause: new UpstreamApiError('etag_mismatch'),
});
}),
+ computeBillingFailure: t.procedure
+ .input(z.enum(['PAYMENT_REQUIRED', 'CONFLICT', 'SERVICE_UNAVAILABLE']))
+ .query(({ input }) => {
+ const billingFailureByCode = {
+ PAYMENT_REQUIRED: 'INSUFFICIENT_CREDITS',
+ CONFLICT: 'COMPUTE_STOPPING',
+ SERVICE_UNAVAILABLE: 'BILLING_UNAVAILABLE',
+ } as const;
+ throw new TRPCError({
+ code: input,
+ message: 'Compute is stopping',
+ cause: {
+ billingFailure: {
+ code: billingFailureByCode[input],
+ payer: { type: 'org', id: 'org-1' },
+ retryable: true,
+ },
+ },
+ });
+ }),
+ legacyPaymentFailure: t.procedure.query(() => {
+ throw new TRPCError({ code: 'PAYMENT_REQUIRED', message: 'Insufficient credits' });
+ }),
});
async function callVerificationProcedure(path: string, input?: unknown): Promise {
@@ -251,3 +274,29 @@ describe('generateTrpcOpenApiDocument', () => {
expect(upstreamFailure.error.data.upstreamCode).toBe('etag_mismatch');
});
});
+
+describe('browser tRPC billing error boundary', () => {
+ it.each([
+ ['PAYMENT_REQUIRED', 'INSUFFICIENT_CREDITS', 402],
+ ['CONFLICT', 'COMPUTE_STOPPING', 409],
+ ['SERVICE_UNAVAILABLE', 'BILLING_UNAVAILABLE', 503],
+ ] as const)('projects explicit %s billing failures', async (code, billingCode, httpStatus) => {
+ const response = TrpcErrorResponseSchema.parse(
+ await callVerificationProcedure('computeBillingFailure', code)
+ );
+ expect(response.error.data.httpStatus).toBe(httpStatus);
+ expect(response.error.data.billingFailure).toEqual({
+ code: billingCode,
+ payer: { type: 'org', id: 'org-1' },
+ retryable: true,
+ });
+ });
+
+ it('does not mark a legacy generic 402 as structured billing failure', async () => {
+ const response = TrpcErrorResponseSchema.parse(
+ await callVerificationProcedure('legacyPaymentFailure')
+ );
+ expect(response.error.data.httpStatus).toBe(402);
+ expect(response.error.data.billingFailure).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/lib/trpc/transport.ts b/apps/web/src/lib/trpc/transport.ts
index 6ab7e37076..02b3dd01d4 100644
--- a/apps/web/src/lib/trpc/transport.ts
+++ b/apps/web/src/lib/trpc/transport.ts
@@ -1,4 +1,5 @@
import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from '@trpc/server';
+import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk';
import * as z from 'zod';
type JsonSchema = Record;
@@ -86,15 +87,22 @@ export type KiloTrpcErrorShape = Omit & {
data: KiloTrpcErrorData;
};
-export const trpcErrorFormatter = (({ shape, error }) => ({
- ...shape,
- data: {
- ...shape.data,
- zodError:
- error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError
- ? z.flattenError(error.cause)
- : null,
- upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined,
- authRequired: error.cause instanceof AuthContextError ? true : undefined,
- },
-})) satisfies TRPCErrorFormatter;
+export const trpcErrorFormatter = (({ shape, error }) => {
+ // Cloud Agent's worker tRPC error is rethrown with a structured cause. Parse
+ // it through the SDK contract before exposing it at the browser boundary.
+ const billingFailure = parseCustomerBillingFailure({ data: error.cause });
+
+ return {
+ ...shape,
+ data: {
+ ...shape.data,
+ zodError:
+ error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError
+ ? z.flattenError(error.cause)
+ : null,
+ upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined,
+ authRequired: error.cause instanceof AuthContextError ? true : undefined,
+ ...(billingFailure ? { billingFailure } : {}),
+ },
+ };
+}) satisfies TRPCErrorFormatter;
diff --git a/apps/web/src/routers/cloud-agent-next-router.ts b/apps/web/src/routers/cloud-agent-next-router.ts
index 9ebb2839b9..edf5dc6089 100644
--- a/apps/web/src/routers/cloud-agent-next-router.ts
+++ b/apps/web/src/routers/cloud-agent-next-router.ts
@@ -444,6 +444,15 @@ export const cloudAgentNextRouter = createTRPCRouter({
return await client.getSession(input.cloudAgentSessionId);
}),
+ getComputeBillingStatus: baseProcedure
+ .input(baseGetSessionNextSchema)
+ .query(async ({ ctx, input }) => {
+ await assertUserOwnsSession(ctx.user.id, input.cloudAgentSessionId);
+ return await createCloudAgentNextClient(
+ generateCloudAgentToken(ctx.user)
+ ).getComputeBillingStatus(input.cloudAgentSessionId);
+ }),
+
checkEligibility: baseProcedure.query(async ({ ctx }) => {
const { balance } = await getBalanceForUser(ctx.user);
return buildCloudAgentNextEligibility(balance);
diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts
index 326292d205..7bb525d792 100644
--- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts
+++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts
@@ -605,6 +605,19 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({
return await client.getSession(input.cloudAgentSessionId);
}),
+ getComputeBillingStatus: organizationMemberProcedure
+ .input(GetSessionInput)
+ .query(async ({ ctx, input }) => {
+ await assertOrganizationOwnsSession({
+ organizationId: input.organizationId,
+ userId: ctx.user.id,
+ cloudAgentSessionId: input.cloudAgentSessionId,
+ });
+ return await createCloudAgentNextClient(
+ generateCloudAgentToken(ctx.user)
+ ).getComputeBillingStatus(input.cloudAgentSessionId);
+ }),
+
checkEligibility: organizationMemberProcedure
.input(z.object({ organizationId: z.uuid() }))
.query(async ({ ctx, input }) => {
diff --git a/packages/cloud-agent-sdk/src/index.ts b/packages/cloud-agent-sdk/src/index.ts
index 5859c8676f..b7b7b7ee39 100644
--- a/packages/cloud-agent-sdk/src/index.ts
+++ b/packages/cloud-agent-sdk/src/index.ts
@@ -3,6 +3,8 @@ export type { CloudAgentEvent, StreamError, StreamErrorCode } from './event-type
export { formatError as formatSessionError } from './session-manager';
export { createSessionManager } from './session-manager';
+export { customerBillingFailureSchema, parseCustomerBillingFailure } from './schemas';
+export type { CustomerBillingFailure } from './schemas';
export { CLI_MODEL_ID, cliModelLabel } from './cli-model';
export type {
ActiveSessionType,
diff --git a/packages/cloud-agent-sdk/src/schemas.test.ts b/packages/cloud-agent-sdk/src/schemas.test.ts
index c9a662d06f..56a35ab0c4 100644
--- a/packages/cloud-agent-sdk/src/schemas.test.ts
+++ b/packages/cloud-agent-sdk/src/schemas.test.ts
@@ -1,4 +1,39 @@
-import { activeSessionSchema } from './schemas';
+import { activeSessionSchema, parseCustomerBillingFailure } from './schemas';
+
+describe('parseCustomerBillingFailure', () => {
+ const failure = {
+ code: 'COMPUTE_STOPPING',
+ payer: { type: 'org', id: 'org-1' },
+ retryable: true,
+ } as const;
+ it.each([
+ { data: { billingFailure: failure } },
+ { shape: { data: { billingFailure: failure } } },
+ ])('parses an explicit billing failure from either tRPC location', error =>
+ expect(parseCustomerBillingFailure(error)).toEqual(failure)
+ );
+ it.each([
+ { data: { billingFailure: { ...failure, payer: { type: 'org' } } } },
+ { data: { billingFailure: { ...failure, remainingMicrodollars: -1 } } },
+ { data: { code: 'PAYMENT_REQUIRED', httpStatus: 402 } },
+ ])('omits malformed or legacy generic errors', error =>
+ expect(parseCustomerBillingFailure(error)).toBeNull()
+ );
+
+ it('preserves zero-valued customer billing balances from the Worker', () => {
+ expect(
+ parseCustomerBillingFailure({
+ data: {
+ billingFailure: {
+ ...failure,
+ remainingMicrodollars: 0,
+ minimumRequiredMicrodollars: 0,
+ },
+ },
+ })
+ ).toMatchObject({ remainingMicrodollars: 0, minimumRequiredMicrodollars: 0 });
+ });
+});
describe('activeSessionSchema capabilities', () => {
it('parses a session whose `capabilities` is absent', () => {
diff --git a/packages/cloud-agent-sdk/src/schemas.ts b/packages/cloud-agent-sdk/src/schemas.ts
index 56b4e41b31..72e09e35d5 100644
--- a/packages/cloud-agent-sdk/src/schemas.ts
+++ b/packages/cloud-agent-sdk/src/schemas.ts
@@ -969,3 +969,25 @@ export const errorShapeSchema = z
})
.passthrough();
export type ErrorShape = z.infer;
+
+// Keep aligned with the customer-safe projection in services/cloud-agent-next/src/trpc-error.ts.
+export const customerBillingFailureSchema = z
+ .object({
+ code: z.enum(['INSUFFICIENT_CREDITS', 'COMPUTE_STOPPING', 'BILLING_UNAVAILABLE']),
+ payer: z.object({ type: z.enum(['user', 'org']), id: z.string().min(1) }).strict(),
+ retryable: z.boolean(),
+ remainingMicrodollars: z.number().int().nonnegative().optional(),
+ minimumRequiredMicrodollars: z.number().int().nonnegative().optional(),
+ })
+ .strict();
+export type CustomerBillingFailure = z.infer;
+
+/** Only accepts the explicit tRPC cause projection; generic 402s stay legacy. */
+export function parseCustomerBillingFailure(error: unknown): CustomerBillingFailure | null {
+ const parsed = errorShapeSchema.safeParse(error);
+ if (!parsed.success) return null;
+ const source =
+ parsed.data.data?.['billingFailure'] ?? parsed.data.shape?.data?.['billingFailure'];
+ const billingFailure = customerBillingFailureSchema.safeParse(source);
+ return billingFailure.success ? billingFailure.data : null;
+}
diff --git a/packages/cloud-agent-sdk/src/session-manager.test.ts b/packages/cloud-agent-sdk/src/session-manager.test.ts
index 8bd811bee6..6f5df97a9b 100644
--- a/packages/cloud-agent-sdk/src/session-manager.test.ts
+++ b/packages/cloud-agent-sdk/src/session-manager.test.ts
@@ -1836,6 +1836,124 @@ describe('createSessionManager', () => {
);
});
+ it('sets structured billing state for prompt and command failures, then clears it with the restored prompt', async () => {
+ const config = createMockConfig();
+ const mgr = createSessionManager(config);
+ await mgr.switchSession(kiloId('ses-1'));
+ const billingFailure = {
+ code: 'COMPUTE_STOPPING',
+ payer: { type: 'org', id: 'org-1' },
+ retryable: true,
+ };
+ mockSession.send.mockRejectedValueOnce({ data: { billingFailure } });
+
+ await expect(
+ mgr.send({
+ payload: {
+ type: 'prompt',
+ prompt: 'Retry this',
+ mode: 'code',
+ model: 'claude-3-5-sonnet',
+ },
+ })
+ ).resolves.toBe(false);
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual(billingFailure);
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Retry this');
+
+ mockSession.send.mockResolvedValueOnce(undefined);
+ await expect(
+ mgr.send({
+ payload: {
+ type: 'prompt',
+ prompt: 'Retry this',
+ mode: 'code',
+ model: 'claude-3-5-sonnet',
+ },
+ })
+ ).resolves.toBe(true);
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull();
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBeNull();
+
+ mockSession.send.mockRejectedValueOnce({ data: { billingFailure } });
+ await mgr.send({ payload: { type: 'command', command: 'help', arguments: '' } });
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual(billingFailure);
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('/help');
+ });
+
+ it('clears structured billing state for a normal failure and on reset', async () => {
+ const config = createMockConfig();
+ const mgr = createSessionManager(config);
+ await mgr.switchSession(kiloId('ses-1'));
+ mockSession.send.mockRejectedValue({
+ data: {
+ billingFailure: {
+ code: 'BILLING_UNAVAILABLE',
+ payer: { type: 'user', id: 'user-1' },
+ retryable: true,
+ },
+ },
+ });
+ await mgr.send({
+ payload: { type: 'prompt', prompt: 'First', mode: 'code', model: 'claude-3-5-sonnet' },
+ });
+ mockSession.send.mockRejectedValueOnce(new Error('ordinary failure'));
+ await mgr.send({
+ payload: { type: 'prompt', prompt: 'Second', mode: 'code', model: 'claude-3-5-sonnet' },
+ });
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull();
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Second');
+ mgr.destroy();
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull();
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBeNull();
+ });
+
+ it.each(['success', 'billing failure'] as const)(
+ 'ignores stale %s state after switching sessions mid-send',
+ async outcome => {
+ let settleSend: ((value?: unknown) => void) | undefined;
+ const pendingSend = new Promise((resolve, reject) => {
+ settleSend = outcome === 'success' ? resolve : reject;
+ });
+ const config = createMockConfig();
+ const mgr = createSessionManager(config);
+ await mgr.switchSession(kiloId('ses-1'));
+ mockSession.send.mockReturnValueOnce(pendingSend);
+
+ const send = mgr.send({
+ payload: { type: 'prompt', prompt: 'Session A', mode: 'code' },
+ });
+ await mgr.switchSession(kiloId('ses-2'));
+ config.store.set(mgr.atoms.failedPrompt, 'Session B');
+ config.store.set(mgr.atoms.billingFailure, {
+ code: 'COMPUTE_STOPPING',
+ payer: { type: 'user', id: 'user-b' },
+ retryable: true,
+ });
+
+ settleSend?.(
+ outcome === 'success'
+ ? undefined
+ : {
+ data: {
+ billingFailure: {
+ code: 'BILLING_UNAVAILABLE',
+ payer: { type: 'user', id: 'user-a' },
+ retryable: true,
+ },
+ },
+ }
+ );
+ await send;
+
+ expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Session B');
+ expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual({
+ code: 'COMPUTE_STOPPING',
+ payer: { type: 'user', id: 'user-b' },
+ retryable: true,
+ });
+ }
+ );
+
it('restores the prompt and explains how to recover from unavailable-model rejection', async () => {
const config = createMockConfig();
const mgr = createSessionManager(config);
diff --git a/packages/cloud-agent-sdk/src/session-manager.ts b/packages/cloud-agent-sdk/src/session-manager.ts
index d329cb78f8..8f3e588a36 100644
--- a/packages/cloud-agent-sdk/src/session-manager.ts
+++ b/packages/cloud-agent-sdk/src/session-manager.ts
@@ -1,6 +1,10 @@
import type { CloudAgentAttachments } from '@kilocode/app-shared/cloud-agent';
import type { Images } from '@kilocode/app-shared/images-schema';
-import { errorShapeSchema } from './schemas';
+import {
+ errorShapeSchema,
+ parseCustomerBillingFailure,
+ type CustomerBillingFailure,
+} from './schemas';
import type {
CreateRemoteSessionInput,
RemoteAttachmentPart,
@@ -357,6 +361,7 @@ type SessionManagerAtoms = {
suggestion: W;
pendingMessages: W>;
failedPrompt: W;
+ billingFailure: W;
fetchedSessionData: W;
/** Slash command catalog reported by the wrapper for the current session. */
availableCommands: W;
@@ -635,6 +640,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
const activeSuggestionAtom = atom(null);
const pendingMessagesAtom = atom>(new Map());
const failedPromptAtom = atom(null);
+ const billingFailureAtom = atom(null);
const fetchedSessionDataAtom = atom(null);
/**
* Catalog of kilo slash commands the wrapper has reported. Populated by
@@ -857,6 +863,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
store.set(activeSuggestionAtom, null);
store.set(pendingMessagesAtom, new Map());
store.set(failedPromptAtom, null);
+ store.set(billingFailureAtom, null);
store.set(fetchedSessionDataAtom, null);
store.set(childSessionHydrationStatesAtom, new Map());
store.set(childSessionErrorsAtom, new Map());
@@ -1821,6 +1828,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
// were current when the user pressed send, not the post-switch ones.
const kiloSessionId = activeSessionId;
const sessionType = activeSessionType;
+ const sessionAtSend = currentSession;
// Client-side `/clear` for remote sessions: clear the local transcript view
// only; never hit the transport (Decision 3/4).
@@ -1879,7 +1887,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
}
try {
- if (!currentSession) throw new Error('No active session');
+ if (!sessionAtSend) throw new Error('No active session');
if (input.attachments && sessionType !== 'cloud-agent') {
// The cloud-only `attachments` field is exclusive to cloud-agent
// sessions. Remote CLI sessions (capable or not) go through the
@@ -1897,7 +1905,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
throw new Error('Only capable remote CLI sessions support attachments');
}
}
- await currentSession.send({
+ await sessionAtSend.send({
payload: transportPayload,
messageId,
...(input.attachments ? { attachments: input.attachments } : {}),
@@ -1907,6 +1915,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
? { attachmentParts: input.attachmentParts }
: {}),
});
+ if (currentSession !== sessionAtSend || activeSessionId !== kiloSessionId) return true;
+ store.set(billingFailureAtom, null);
+ store.set(failedPromptAtom, null);
// User continued after `/clear`: drop the marker so a later reconnect
// replays full history (pre-clear may reappear — accepted tradeoff).
@@ -1921,7 +1932,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
}
return true;
} catch (err) {
+ if (currentSession !== sessionAtSend || activeSessionId !== kiloSessionId) return false;
store.set(failedPromptAtom, messageText);
+ store.set(billingFailureAtom, parseCustomerBillingFailure(err));
const message = formatError(err);
config.onSendFailed?.(messageText, message, err);
if (store.get(agentStatusAtom).type !== 'disconnected') {
@@ -2191,6 +2204,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
activeSuggestion: activeSuggestionAtom,
pendingMessages: pendingMessagesAtom,
failedPrompt: failedPromptAtom,
+ billingFailure: billingFailureAtom,
fetchedSessionData: fetchedSessionDataAtom,
availableCommands: availableCommandsAtom,
messagesList: messagesListAtom,
diff --git a/packages/container-usage/src/contracts.ts b/packages/container-usage/src/contracts.ts
index 67ea5082ee..b645dbd31b 100644
--- a/packages/container-usage/src/contracts.ts
+++ b/packages/container-usage/src/contracts.ts
@@ -132,13 +132,14 @@ export const recordStartFailureCodeSchema = z.enum([
]);
export type RecordStartFailureCode = z.infer;
+/** Customer-safe billing admission failure carried across Worker and client boundaries. */
const recordStartFailureSchema = z.discriminatedUnion('code', [
z
.object({
code: z.literal('insufficient_credits'),
message: z.string().min(1),
- remainingMicrodollars: z.number().int().optional(),
- minimumRequiredMicrodollars: z.number().int().positive().optional(),
+ remainingMicrodollars: z.number().int().nonnegative().optional(),
+ minimumRequiredMicrodollars: z.number().int().nonnegative().optional(),
})
.strict(),
z
@@ -160,8 +161,8 @@ export const budgetVerdictSchema = z.discriminatedUnion('verdict', [
z
.object({
verdict: z.literal('warn'),
- remainingMicrodollars: z.number().int().optional(),
- minimumRequiredMicrodollars: z.number().int().positive().optional(),
+ remainingMicrodollars: z.number().int().nonnegative().optional(),
+ minimumRequiredMicrodollars: z.number().int().nonnegative().optional(),
// Retained while already-deployed producers complete their protocol rollout.
remaining: z.number().int().optional(),
})
@@ -169,8 +170,8 @@ export const budgetVerdictSchema = z.discriminatedUnion('verdict', [
z
.object({
verdict: z.literal('stop'),
- remainingMicrodollars: z.number().int().optional(),
- minimumRequiredMicrodollars: z.number().int().positive().optional(),
+ remainingMicrodollars: z.number().int().nonnegative().optional(),
+ minimumRequiredMicrodollars: z.number().int().nonnegative().optional(),
remaining: z.number().int().optional(),
})
.strict(),
diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts
index 4aca8edeff..cd815cc994 100644
--- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts
+++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts
@@ -79,6 +79,7 @@ import {
ensureSandboxBillingAdmissionInput,
isSandboxBillingBlocked,
isSandboxContainerRunning,
+ getSandboxBillingRuntimeStatus,
type SandboxBillingInput,
} from '../../container-usage-context.js';
import { isCloudAgentContainerBillingEnabled } from '../../container-billing-rollout.js';
@@ -285,6 +286,12 @@ export class CloudflareAgentSandbox implements AgentSandbox {
return this.sandboxBillingBlocked(this.resolveSandbox(sandboxId), enforcementRequested);
}
+ async getBillingRuntimeStatus() {
+ const sandboxId = await this.resolveSandboxId();
+ const status = await getSandboxBillingRuntimeStatus(this.resolveSandbox(sandboxId));
+ return status ? { ...status, sandboxId } : undefined;
+ }
+
private async getSandbox(options?: {
sleepAfter?: number;
bypassBilling?: boolean;
diff --git a/services/cloud-agent-next/src/agent-sandbox/protocol.ts b/services/cloud-agent-next/src/agent-sandbox/protocol.ts
index 979e311066..b4be31f3ee 100644
--- a/services/cloud-agent-next/src/agent-sandbox/protocol.ts
+++ b/services/cloud-agent-next/src/agent-sandbox/protocol.ts
@@ -8,6 +8,9 @@ import type {
} from '../execution/types.js';
import type { SessionMetadata } from '../persistence/session-metadata.js';
import type { SandboxBillingAdmissionResult } from '../container-usage-context.js';
+import type { SandboxClassName } from '../container-usage-context.js';
+import type { BillingContext } from '@kilocode/container-usage';
+import type { SandboxId } from '../types.js';
export type SandboxDeleteReason = 'explicit' | 'retention-expired' | 'recovery';
@@ -133,6 +136,16 @@ export type EnsuredWrapper =
export type AgentSandbox = {
ensureBillingAdmission(): Promise;
isBillingBlocked(enforcementRequested?: boolean): Promise;
+ getBillingRuntimeStatus(): Promise<
+ | {
+ sandboxId: SandboxId;
+ sandboxClassName: SandboxClassName;
+ running: boolean;
+ blocked: boolean;
+ context?: BillingContext;
+ }
+ | undefined
+ >;
ensureWrapper(request: EnsureWrapperRequest): Promise;
discoverSessionWrappers(): Promise;
/**
diff --git a/services/cloud-agent-next/src/container-usage-context.test.ts b/services/cloud-agent-next/src/container-usage-context.test.ts
index 1d20d70601..948fa87c05 100644
--- a/services/cloud-agent-next/src/container-usage-context.test.ts
+++ b/services/cloud-agent-next/src/container-usage-context.test.ts
@@ -5,6 +5,7 @@ import {
assertSandboxBillingAllocation,
buildSandboxBillingInput,
configureSandboxBillingInput,
+ getSandboxBillingRuntimeStatus,
SANDBOX_CAPACITIES,
SANDBOX_USAGE_SKUS,
} from './container-usage-context.js';
@@ -20,6 +21,28 @@ function metadata(identity: SessionMetadata['identity']): SessionMetadata {
}
describe('container usage context', () => {
+ it('invokes billing status as a binding method for RPC proxies', async () => {
+ const status = {
+ sandboxClassName: 'SandboxSmall' as const,
+ running: true,
+ blocked: false,
+ };
+ const getBillingRuntimeStatus = new Proxy(
+ vi.fn(async () => status),
+ {
+ get: (target, property, receiver) => {
+ if (property === 'call')
+ throw new Error('RPC method proxies do not support Function.call');
+ return Reflect.get(target, property, receiver);
+ },
+ }
+ );
+
+ await expect(
+ getSandboxBillingRuntimeStatus({ getBillingRuntimeStatus } as unknown as SandboxInstance)
+ ).resolves.toEqual(status);
+ });
+
it('maps every concrete sandbox class to its immutable SKU', () => {
expect(SANDBOX_USAGE_SKUS).toEqual({
Sandbox: 'cloud-agent-standard-2026-07',
diff --git a/services/cloud-agent-next/src/container-usage-context.ts b/services/cloud-agent-next/src/container-usage-context.ts
index 9edacdc2ce..43425127fb 100644
--- a/services/cloud-agent-next/src/container-usage-context.ts
+++ b/services/cloud-agent-next/src/container-usage-context.ts
@@ -9,6 +9,7 @@ import { z } from 'zod';
import { logger } from './logger.js';
import type { SessionMetadata } from './persistence/session-metadata.js';
import type { SandboxId, SandboxInstance } from './types.js';
+import type { BillingContext } from '@kilocode/container-usage';
export const SANDBOX_USAGE_SKUS = {
Sandbox: 'cloud-agent-standard-2026-07',
@@ -55,6 +56,17 @@ export type MeteredSandboxInstance = SandboxInstance & {
ensureBillingAdmission(input: unknown): Promise;
isBillingBlocked(): Promise;
isContainerRunning(): Promise;
+ getBillingRuntimeStatus(): Promise<{
+ sandboxClassName: SandboxClassName;
+ running: boolean;
+ blocked: boolean;
+ context?: BillingContext;
+ }>;
+};
+
+/** Optional while Worker and sandbox runtime deployments roll forward independently. */
+type BillingRuntimeStatusCapability = {
+ getBillingRuntimeStatus?: MeteredSandboxInstance['getBillingRuntimeStatus'];
};
const sandboxBillingInputEnvelopeSchema = z
@@ -291,6 +303,20 @@ export async function isSandboxContainerRunning(
}
}
+export async function getSandboxBillingRuntimeStatus(sandbox: SandboxInstance): Promise<
+ | {
+ sandboxClassName: SandboxClassName;
+ running: boolean;
+ blocked: boolean;
+ context?: BillingContext;
+ }
+ | undefined
+> {
+ const capability = sandbox as BillingRuntimeStatusCapability;
+ if (typeof capability.getBillingRuntimeStatus !== 'function') return undefined;
+ return await capability.getBillingRuntimeStatus();
+}
+
export async function configureSandboxBillingInput(
sandbox: SandboxInstance,
input: SandboxBillingInput
diff --git a/services/cloud-agent-next/src/container-usage.test.ts b/services/cloud-agent-next/src/container-usage.test.ts
index cdc7e6f9cf..91486355b9 100644
--- a/services/cloud-agent-next/src/container-usage.test.ts
+++ b/services/cloud-agent-next/src/container-usage.test.ts
@@ -74,7 +74,7 @@ const sdk = vi.hoisted(() => {
vi.mock('@cloudflare/sandbox', () => ({ Sandbox: sdk.StockSandbox }));
-import { MeteredSandbox } from './container-usage.js';
+import { billingHeartbeatSeconds, MeteredSandbox } from './container-usage.js';
class MemoryStorage {
private readonly values = new Map();
@@ -99,6 +99,10 @@ class MemoryStorage {
clear(): void {
this.values.clear();
}
+
+ size(): number {
+ return this.values.size;
+ }
}
function ack(intervalId = 'interval-1') {
@@ -139,7 +143,8 @@ function createSandbox(
| 'Sandbox'
| 'SandboxContainment'
| 'SandboxSmallContainment'
- | 'SandboxDIND' = 'SandboxSmallContainment'
+ | 'SandboxDIND' = 'SandboxSmallContainment',
+ heartbeatSeconds?: string
) {
const storage = new MemoryStorage();
const shadowTasks: Promise[] = [];
@@ -169,6 +174,7 @@ function createSandbox(
flushShadowTasks: () => Promise.all(shadowTasks),
sandbox: new TestSandbox(ctx, {
CONTAINER_USAGE_METER: rpc,
+ CONTAINER_BILLING_HEARTBEAT_SECONDS: heartbeatSeconds,
} as never) as unknown as TestRuntime,
};
}
@@ -182,14 +188,43 @@ const billingInput = {
};
describe('MeteredSandbox', () => {
+ it('uses a configurable positive heartbeat interval with the production default as fallback', () => {
+ expect(billingHeartbeatSeconds('60')).toBe(60);
+ expect(billingHeartbeatSeconds(undefined)).toBe(300);
+ expect(billingHeartbeatSeconds('')).toBe(300);
+ expect(billingHeartbeatSeconds('0')).toBe(300);
+ expect(billingHeartbeatSeconds('-1')).toBe(300);
+ expect(billingHeartbeatSeconds('not-a-number')).toBe(300);
+ });
+
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
+ it('reads billing runtime status without creating a billing generation or waking the container', async () => {
+ const { sandbox, storage, rpc } = createSandbox(createRpc(), false, 'SandboxSmallContainment');
+
+ await expect(sandbox.getBillingRuntimeStatus()).resolves.toEqual({
+ sandboxClassName: 'SandboxSmallContainment',
+ running: false,
+ blocked: false,
+ context: undefined,
+ });
+
+ expect(storage.size()).toBe(0);
+ expect(rpc.recordStart).not.toHaveBeenCalled();
+ expect(rpc.recordHeartbeat).not.toHaveBeenCalled();
+ });
+
it('accepts any successful meter admission before a selected cold start', async () => {
const rpc = createRpc();
- const { sandbox, flushShadowTasks } = createSandbox(rpc);
+ const { sandbox, flushShadowTasks } = createSandbox(
+ rpc,
+ false,
+ 'SandboxSmallContainment',
+ '60'
+ );
await expect(
sandbox.ensureBillingAdmission({ ...billingInput, enforcementRequested: true })
@@ -202,7 +237,7 @@ describe('MeteredSandbox', () => {
await sandbox.onStart();
await flushShadowTasks();
expect(sandbox.schedules).toEqual([
- expect.objectContaining({ callback: 'billingHeartbeatTick' }),
+ expect.objectContaining({ when: 60, callback: 'billingHeartbeatTick' }),
]);
});
diff --git a/services/cloud-agent-next/src/container-usage.ts b/services/cloud-agent-next/src/container-usage.ts
index 8dab646145..95333d2803 100644
--- a/services/cloud-agent-next/src/container-usage.ts
+++ b/services/cloud-agent-next/src/container-usage.ts
@@ -1,6 +1,7 @@
import {
clearBillingContext,
createContainerUsageClient,
+ DEFAULT_BILLING_HEARTBEAT_SECONDS,
getBillingContext,
installBillingHeartbeat,
setBillingContext,
@@ -37,6 +38,12 @@ const DESTROY_RECOVERY_MARKER_STORAGE_KEY_PREFIX = 'container-usage:destroy-reco
const BILLING_FORCE_STOP_SECONDS = 120;
const BILLING_FORCE_STOP_RETRY_SECONDS = 5;
+export function billingHeartbeatSeconds(value: string | undefined): number {
+ if (value === undefined || value.trim() === '') return DEFAULT_BILLING_HEARTBEAT_SECONDS;
+ const seconds = Number(value);
+ return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : DEFAULT_BILLING_HEARTBEAT_SECONDS;
+}
+
// oxlint-disable-next-line no-empty-object-type -- Matches the Sandbox 0.12.1 constructor.
type SandboxDurableObjectState = DurableObjectState<{}>;
type ContainerStopParams = { reason: 'exit' | 'runtime_signal'; exitCode?: number };
@@ -102,6 +109,7 @@ export abstract class MeteredSandbox extends StockSandbox {
this.billingHeartbeat = installBillingHeartbeat(this, {
client: this.usageClient,
storage: this.ctx.storage,
+ heartbeatSeconds: billingHeartbeatSeconds(env.CONTAINER_BILLING_HEARTBEAT_SECONDS),
stopOnStoppedState: false,
deferBudgetStopFinalSettlement: true,
beforeHeartbeatDelivery: context => this.ensureStartAcknowledged(context),
@@ -133,6 +141,21 @@ export abstract class MeteredSandbox extends StockSandbox {
return (await this.getBillingBlock()) !== undefined;
}
+ /** Read-only status: storage and container state only; this never wakes or admits. */
+ async getBillingRuntimeStatus(): Promise<{
+ sandboxClassName: SandboxClassName;
+ running: boolean;
+ blocked: boolean;
+ context?: BillingContext;
+ }> {
+ return {
+ sandboxClassName: this.sandboxClassName,
+ running: this.ctx.container?.running === true,
+ blocked: (await this.getBillingBlock()) !== undefined,
+ context: await getBillingContext(this.ctx.storage),
+ };
+ }
+
async ensureBillingAdmission(input: unknown): Promise {
const parsed = parseSandboxBillingInput(input);
assertSandboxBillingAllocation(this.sandboxClassName, parsed);
diff --git a/services/cloud-agent-next/src/execution/types.ts b/services/cloud-agent-next/src/execution/types.ts
index 721b2ad737..9ed2014627 100644
--- a/services/cloud-agent-next/src/execution/types.ts
+++ b/services/cloud-agent-next/src/execution/types.ts
@@ -230,6 +230,15 @@ export type RetryableResultCode =
export type PermanentDeliveryResultCode = 'SANDBOX_CAPABILITY_UNAVAILABLE';
+// Keep aligned with the customer-safe schemas in trpc-error.ts and cloud-agent-sdk.
+export type CustomerBillingFailure = {
+ code: 'INSUFFICIENT_CREDITS' | 'COMPUTE_STOPPING' | 'BILLING_UNAVAILABLE';
+ payer: { type: 'user' | 'org'; id: string };
+ retryable: boolean;
+ remainingMicrodollars?: number;
+ minimumRequiredMicrodollars?: number;
+};
+
export type AdmissionFailure = {
success: false;
code:
@@ -237,9 +246,12 @@ export type AdmissionFailure = {
| 'BAD_REQUEST'
| 'INTERNAL'
| 'PAYMENT_REQUIRED'
+ | 'COMPUTE_STOPPING'
+ | 'BILLING_UNAVAILABLE'
| 'PENDING_QUEUE_FULL'
| RetryableResultCode;
error: string;
+ billingFailure?: CustomerBillingFailure;
failureBoundary?: 'registration' | 'admission';
};
diff --git a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts
index dcd4a2f641..9aab3e8e40 100644
--- a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts
+++ b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts
@@ -676,6 +676,14 @@ function promptAdmissionError(
return facadeError(429, 'KILO_PROMPT_QUEUE_FULL', result.error);
case 'PAYMENT_REQUIRED':
return facadeError(402, 'KILO_PROMPT_PAYMENT_REQUIRED', result.error);
+ case 'COMPUTE_STOPPING':
+ return facadeError(409, 'KILO_BILLING_BLOCKED', 'Cloud Agent is saving and stopping compute');
+ case 'BILLING_UNAVAILABLE':
+ return facadeError(
+ 503,
+ 'KILO_BILLING_UNAVAILABLE',
+ 'Cloud Agent cannot verify compute billing right now'
+ );
case 'SANDBOX_CONNECT_FAILED':
case 'WORKSPACE_SETUP_FAILED':
case 'KILO_SERVER_FAILED':
diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
index c9e67dc8b0..fdcc058dc4 100644
--- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
+++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts
@@ -756,14 +756,40 @@ export class CloudAgentSession extends DurableObject {
if (!isCloudAgentContainerBillingEnabled(this.env, metadata.identity)) return null;
const admission = await createAgentSandbox(this.env, metadata).ensureBillingAdmission();
if (admission.success) return null;
- const paymentRequired =
- admission.code === 'insufficient_credits' || admission.code === 'stopping';
+ const payer = metadata.identity.orgId
+ ? { type: 'org' as const, id: metadata.identity.orgId }
+ : { type: 'user' as const, id: metadata.identity.userId };
+ const billingFailure =
+ admission.code === 'insufficient_credits'
+ ? {
+ code: 'INSUFFICIENT_CREDITS' as const,
+ payer,
+ retryable: false,
+ ...(admission.remainingMicrodollars === undefined
+ ? {}
+ : { remainingMicrodollars: admission.remainingMicrodollars }),
+ ...(admission.minimumRequiredMicrodollars === undefined
+ ? {}
+ : { minimumRequiredMicrodollars: admission.minimumRequiredMicrodollars }),
+ }
+ : admission.code === 'stopping'
+ ? { code: 'COMPUTE_STOPPING' as const, payer, retryable: true }
+ : { code: 'BILLING_UNAVAILABLE' as const, payer, retryable: true };
return {
success: false,
- code: paymentRequired ? 'PAYMENT_REQUIRED' : 'INTERNAL',
- error: paymentRequired
- ? 'Container billing requires additional credits'
- : 'Container billing admission is temporarily unavailable',
+ code:
+ admission.code === 'insufficient_credits'
+ ? 'PAYMENT_REQUIRED'
+ : admission.code === 'stopping'
+ ? 'COMPUTE_STOPPING'
+ : 'BILLING_UNAVAILABLE',
+ error:
+ admission.code === 'insufficient_credits'
+ ? 'Insufficient credits to start compute'
+ : admission.code === 'stopping'
+ ? 'Cloud Agent is saving and stopping compute'
+ : 'Cloud Agent cannot verify compute billing right now',
+ billingFailure,
failureBoundary: 'admission',
};
}
diff --git a/services/cloud-agent-next/src/router/handlers/session-management.ts b/services/cloud-agent-next/src/router/handlers/session-management.ts
index 4f90a80458..d6afe6bba1 100644
--- a/services/cloud-agent-next/src/router/handlers/session-management.ts
+++ b/services/cloud-agent-next/src/router/handlers/session-management.ts
@@ -3,7 +3,7 @@ import * as z from 'zod';
import { AgentSandboxUnavailableError } from '../../agent-sandbox/protocol.js';
import { createAgentSandbox } from '../../agent-sandbox/factory.js';
import { logger, withLogTags } from '../../logger.js';
-import { generateSandboxId } from '../../sandbox-id.js';
+import { generateSandboxId, isGeneratedSharedSandboxId } from '../../sandbox-id.js';
import type { SessionId, InterruptResult, TRPCContext } from '../../types.js';
import type { SandboxId } from '../../types.js';
import {
@@ -23,12 +23,17 @@ import {
GetMessageResultOutput,
GetLatestAssistantMessageInput,
GetLatestAssistantMessageOutput,
+ GetComputeBillingStatusOutput,
} from '../schemas.js';
import { readProfileBundle } from '../../session-profile.js';
import type { CloudAgentSession } from '../../persistence/CloudAgentSession.js';
import type { CloudAgentSessionState } from '../../persistence/types.js';
import type { MessageResultRPCResponse } from '../../session/message-result.js';
import { requireCurrentSessionAccess } from '../../session-access.js';
+import { getPgDb } from '../../db/pg.js';
+import { cloud_billing_sku, container_usage_interval } from '@kilocode/db/schema';
+import { and, desc, eq, like } from 'drizzle-orm';
+import { SANDBOX_USAGE_SKUS } from '../../container-usage-context.js';
function publicRepositoryFields(metadata: CloudAgentSessionState): {
githubRepo?: string;
@@ -49,6 +54,17 @@ function publicRepositoryFields(metadata: CloudAgentSessionState): {
}
}
+function toIso(value: string): string {
+ return new Date(value).toISOString();
+}
+
+function microdollarsForSeconds(seconds: number, rateCentsPerSecond: string): number {
+ const [whole, fraction = ''] = rateCentsPerSecond.split('.');
+ const scale = 10n ** BigInt(fraction.length);
+ const cents = BigInt(`${whole}${fraction}` || '0');
+ return Number((BigInt(seconds) * cents * 10_000n) / scale);
+}
+
async function deleteSessionResources(
sessionId: SessionId,
userId: string,
@@ -374,6 +390,144 @@ export function createSessionManagementHandlers() {
});
}),
+ getComputeBillingStatus: protectedProcedure
+ .input(GetSessionInput)
+ .output(GetComputeBillingStatusOutput)
+ .query(async ({ input, ctx }) => {
+ const sessionId = input.cloudAgentSessionId as SessionId;
+ await requireCurrentSessionAccess({
+ env: ctx.env,
+ kiloUserId: ctx.userId,
+ cloudAgentSessionId: sessionId,
+ });
+ const stub = ctx.env.CLOUD_AGENT_SESSION.get(
+ ctx.env.CLOUD_AGENT_SESSION.idFromName(`${ctx.userId}:${sessionId}`)
+ );
+ const metadata = await withDORetry<
+ DurableObjectStub,
+ CloudAgentSessionState | null
+ >(
+ () => stub,
+ value => value.getMetadata(),
+ 'getMetadata'
+ );
+ if (!metadata) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' });
+
+ const sandbox = createAgentSandbox(ctx.env, metadata);
+ // This is a no-wake status RPC. Older/rejecting runtime deployments are
+ // unavailable rather than a reason to create, wake, or admit compute.
+ const runtime = await sandbox.getBillingRuntimeStatus().catch(() => undefined);
+ const payer = metadata.identity.orgId
+ ? { type: 'org' as const, id: metadata.identity.orgId }
+ : { type: 'user' as const, id: metadata.identity.userId };
+ // The metered runtime has the canonical resolved ID (including a
+ // persisted failover ID). When it is not available, only trust stored
+ // metadata; never regenerate an ID for a read-only billing status.
+ const sandboxId = runtime?.sandboxId ?? metadata.workspace?.sandboxId;
+ const db = getPgDb(ctx.env);
+ const catalogSkuId = runtime ? SANDBOX_USAGE_SKUS[runtime.sandboxClassName] : undefined;
+ const [catalog] = catalogSkuId
+ ? await db
+ .select({ rate: cloud_billing_sku.rate_cents_per_unit, unit: cloud_billing_sku.unit })
+ .from(cloud_billing_sku)
+ .where(eq(cloud_billing_sku.id, catalogSkuId))
+ .limit(1)
+ : [];
+ const latest = sandboxId
+ ? await db
+ .select({
+ id: container_usage_interval.id,
+ billingMode: container_usage_interval.billing_mode,
+ rate: container_usage_interval.rate_cents_per_unit,
+ skuId: container_usage_interval.cloud_billing_sku_id,
+ startedAt: container_usage_interval.started_at,
+ lastSeenAt: container_usage_interval.last_seen_at,
+ stoppedAt: container_usage_interval.stopped_at,
+ confirmedSeconds: container_usage_interval.confirmed_seconds,
+ settledBillableSeconds: container_usage_interval.settled_billable_seconds,
+ status: container_usage_interval.status,
+ skuRate: cloud_billing_sku.rate_cents_per_unit,
+ })
+ .from(container_usage_interval)
+ .leftJoin(
+ cloud_billing_sku,
+ eq(cloud_billing_sku.id, container_usage_interval.cloud_billing_sku_id)
+ )
+ .where(
+ and(
+ eq(container_usage_interval.instance_id, sandboxId),
+ eq(container_usage_interval.subject_type, payer.type),
+ eq(container_usage_interval.subject_id, payer.id),
+ like(container_usage_interval.service, 'cloud-agent-next-%')
+ )
+ )
+ .orderBy(desc(container_usage_interval.started_at))
+ .limit(1)
+ : [];
+ const interval = latest[0];
+ // A closed row is historical evidence, not the current running
+ // interval. Paid intervals retain their admitted snapshot; shadow
+ // intervals use today's catalog rate for the current runtime class.
+ const hasCurrentInterval = interval?.status === 'open';
+ const catalogRate = catalog?.unit === 'second' ? catalog.rate : null;
+ // Paid interval rates are admitted snapshots for second-based container usage.
+ const rate = hasCurrentInterval
+ ? interval.billingMode === 'paid'
+ ? interval.rate
+ : catalogRate
+ : catalogRate;
+ const attribution =
+ sandboxId && isGeneratedSharedSandboxId(sandboxId)
+ ? ('payer_shared' as const)
+ : ('session' as const);
+ const phase = !runtime
+ ? ('unavailable' as const)
+ : runtime.context || hasCurrentInterval
+ ? runtime.blocked
+ ? ('stopping' as const)
+ : runtime.running
+ ? ('active' as const)
+ : ('settling' as const)
+ : ('idle' as const);
+ const confirmedSeconds = hasCurrentInterval ? (interval?.confirmedSeconds ?? 0) : 0;
+ // This display-only elapsed estimate never drives settlement; the meter is authoritative.
+ const observedSeconds =
+ hasCurrentInterval && interval
+ ? Math.max(
+ confirmedSeconds,
+ Math.floor(
+ ((interval.stoppedAt ? new Date(interval.stoppedAt).getTime() : Date.now()) -
+ new Date(interval.startedAt).getTime()) /
+ 1_000
+ )
+ )
+ : 0;
+ return {
+ payer,
+ attribution,
+ phase,
+ estimatedHourlyRateMicrodollars: rate ? microdollarsForSeconds(3600, rate) : null,
+ estimatedIntervalAmountMicrodollars:
+ phase === 'active' || phase === 'stopping'
+ ? rate
+ ? microdollarsForSeconds(observedSeconds, rate)
+ : null
+ : null,
+ billingMode: hasCurrentInterval ? (interval?.billingMode ?? null) : null,
+ interval:
+ hasCurrentInterval && interval
+ ? {
+ id: interval.id,
+ startedAt: toIso(interval.startedAt),
+ lastSeenAt: toIso(interval.lastSeenAt),
+ stoppedAt: interval.stoppedAt ? toIso(interval.stoppedAt) : null,
+ confirmedSeconds,
+ settledBillableSeconds: interval.settledBillableSeconds,
+ }
+ : null,
+ };
+ }),
+
getSessionHealth: protectedProcedure
.input(GetSessionHealthInput)
.output(GetSessionHealthOutput)
diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts
index 83a7984922..0b9711be4c 100644
--- a/services/cloud-agent-next/src/router/schemas.ts
+++ b/services/cloud-agent-next/src/router/schemas.ts
@@ -924,6 +924,26 @@ export const GetSessionInput = z.object({
cloudAgentSessionId: sessionIdSchema.describe('Cloud-agent session ID to retrieve'),
});
+/** Customer-safe, no-wake compute billing status for an existing session. */
+export const GetComputeBillingStatusOutput = z.object({
+ payer: z.object({ type: z.enum(['user', 'org']), id: z.string() }),
+ attribution: z.enum(['payer_shared', 'session']),
+ phase: z.enum(['idle', 'active', 'stopping', 'settling', 'unavailable']),
+ estimatedHourlyRateMicrodollars: z.number().int().nonnegative().nullable(),
+ estimatedIntervalAmountMicrodollars: z.number().int().nonnegative().nullable(),
+ billingMode: z.enum(['shadow', 'paid']).nullable(),
+ interval: z
+ .object({
+ id: z.string(),
+ startedAt: z.string().datetime(),
+ lastSeenAt: z.string().datetime(),
+ stoppedAt: z.string().datetime().nullable(),
+ confirmedSeconds: z.number().int().nonnegative(),
+ settledBillableSeconds: z.number().int().nonnegative(),
+ })
+ .nullable(),
+});
+
export const SandboxStatusSchema = z
.enum(['healthy', 'destroyed', 'unreachable', 'unknown'])
.describe('Sandbox reachability status for the session container');
diff --git a/services/cloud-agent-next/src/session/pending-messages.ts b/services/cloud-agent-next/src/session/pending-messages.ts
index cda48d25d9..1e2d14751f 100644
--- a/services/cloud-agent-next/src/session/pending-messages.ts
+++ b/services/cloud-agent-next/src/session/pending-messages.ts
@@ -100,6 +100,8 @@ const PendingFlushFailureCodeSchema = z.enum([
'INTERNAL',
'PENDING_QUEUE_FULL',
'MODEL_MISSING',
+ 'COMPUTE_STOPPING',
+ 'BILLING_UNAVAILABLE',
'UNKNOWN',
]);
export type PendingFlushFailureCode = z.infer;
@@ -540,6 +542,8 @@ export async function recordPendingFlushFailure(
| 'INTERNAL'
| 'PENDING_QUEUE_FULL'
| 'MODEL_MISSING'
+ | 'COMPUTE_STOPPING'
+ | 'BILLING_UNAVAILABLE'
| 'UNKNOWN';
subtype?: WorkspaceFailureSubtype;
safeFailureMessage?: string;
@@ -636,6 +640,8 @@ function isRetryableFlushCode(
| 'INTERNAL'
| 'PENDING_QUEUE_FULL'
| 'MODEL_MISSING'
+ | 'COMPUTE_STOPPING'
+ | 'BILLING_UNAVAILABLE'
| 'UNKNOWN'
| undefined
): boolean {
@@ -646,6 +652,8 @@ function isRetryableFlushCode(
code === 'WORKSPACE_SETUP_FAILED' ||
code === 'KILO_SERVER_FAILED' ||
code === 'WRAPPER_START_FAILED' ||
+ code === 'COMPUTE_STOPPING' ||
+ code === 'BILLING_UNAVAILABLE' ||
code === 'WRAPPER_CLEANUP_EXHAUSTED'
);
}
diff --git a/services/cloud-agent-next/src/session/queue-message.ts b/services/cloud-agent-next/src/session/queue-message.ts
index 0deef65b42..017df20cea 100644
--- a/services/cloud-agent-next/src/session/queue-message.ts
+++ b/services/cloud-agent-next/src/session/queue-message.ts
@@ -44,12 +44,20 @@ const ADMISSION_CODE_TO_TRPC: Record =
NOT_FOUND: 'NOT_FOUND',
BAD_REQUEST: 'BAD_REQUEST',
PAYMENT_REQUIRED: 'PAYMENT_REQUIRED',
+ COMPUTE_STOPPING: 'CONFLICT',
+ BILLING_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
PENDING_QUEUE_FULL: 'TOO_MANY_REQUESTS',
INTERNAL: 'INTERNAL_SERVER_ERROR',
};
function isAdmissionFailureRetryable(code: AdmissionFailureCode): boolean {
- return isRetryableCode(code) || code === 'PENDING_QUEUE_FULL' || code === 'INTERNAL';
+ return (
+ isRetryableCode(code) ||
+ code === 'PENDING_QUEUE_FULL' ||
+ code === 'INTERNAL' ||
+ code === 'COMPUTE_STOPPING' ||
+ code === 'BILLING_UNAVAILABLE'
+ );
}
export function throwAdmissionError(
@@ -66,6 +74,7 @@ export function throwAdmissionError(
error: result.code,
message: result.error,
retryable: explicitlyRetryable,
+ ...(result.billingFailure ? { billingFailure: result.billingFailure } : {}),
},
});
}
diff --git a/services/cloud-agent-next/src/session/session-message-queue.test.ts b/services/cloud-agent-next/src/session/session-message-queue.test.ts
index 282884801e..0a8bc493d4 100644
--- a/services/cloud-agent-next/src/session/session-message-queue.test.ts
+++ b/services/cloud-agent-next/src/session/session-message-queue.test.ts
@@ -418,6 +418,116 @@ describe('flushNextPendingSessionMessage', () => {
expect((await storage.list({ prefix: 'pending_message:' })).size).toBe(0);
});
+ it.each(['COMPUTE_STOPPING', 'BILLING_UNAVAILABLE'] as const)(
+ 'retries %s billing admission and preserves the durable message identity',
+ async code => {
+ const storage = createMemoryStorage();
+ await storePendingSessionMessage(
+ storage,
+ createPendingSessionMessage({
+ messageId: FIRST_MESSAGE_ID,
+ role: 'user',
+ content: 'retry after compute state settles',
+ createdAt: 1,
+ })
+ );
+ const deliver = vi
+ .fn<(_plan: MessageDeliveryRequest) => Promise>()
+ .mockResolvedValueOnce({ success: false, code, error: 'temporary billing state' })
+ .mockResolvedValueOnce({
+ success: true,
+ outcome: 'accepted',
+ messageId: FIRST_MESSAGE_ID,
+ wrapperRunId: 'wr_test',
+ });
+
+ const first = await flushNextPendingSessionMessage({
+ storage,
+ now: 10,
+ getDeliveryContext: async () => createContext(),
+ validateModeAgainstRuntimeAgents: () => null,
+ deliver,
+ });
+ expect(first).toMatchObject({ type: 'failure', exhausted: false, attempts: 1 });
+ if (first.type !== 'failure') return;
+ expect(first.message.lastFlushFailureCode).toBe(code);
+
+ await expect(
+ flushNextPendingSessionMessage({
+ storage,
+ now: first.nextFlushAttemptAt ?? 20,
+ getDeliveryContext: async () => createContext(),
+ validateModeAgainstRuntimeAgents: () => null,
+ deliver,
+ })
+ ).resolves.toEqual({ type: 'delivered', remainingCount: 0 });
+ expect(deliver.mock.calls.map(([plan]) => plan.turn.messageId)).toEqual([
+ FIRST_MESSAGE_ID,
+ FIRST_MESSAGE_ID,
+ ]);
+ }
+ );
+
+ it('fails closed after the retry budget for unavailable billing', async () => {
+ const storage = createMemoryStorage();
+ await storePendingSessionMessage(
+ storage,
+ createPendingSessionMessage({
+ messageId: FIRST_MESSAGE_ID,
+ role: 'user',
+ content: 'eventually terminal',
+ createdAt: 1,
+ })
+ );
+ const deliver = async (): Promise => ({
+ success: false,
+ code: 'BILLING_UNAVAILABLE',
+ error: 'temporary billing state',
+ });
+ const first = await flushNextPendingSessionMessage({
+ storage,
+ now: 10,
+ getDeliveryContext: async () => createContext(),
+ validateModeAgainstRuntimeAgents: () => null,
+ deliver,
+ });
+ if (first.type !== 'failure') throw new Error('Expected failure');
+ const exhausted = await flushNextPendingSessionMessage({
+ storage,
+ now: first.nextFlushAttemptAt ?? 20,
+ getDeliveryContext: async () => createContext(),
+ validateModeAgainstRuntimeAgents: () => null,
+ deliver,
+ });
+ expect(exhausted).toMatchObject({ type: 'failure', exhausted: true, attempts: 2 });
+ });
+
+ it('terminalizes insufficient credits immediately without a retry', async () => {
+ const storage = createMemoryStorage();
+ await storePendingSessionMessage(
+ storage,
+ createPendingSessionMessage({
+ messageId: FIRST_MESSAGE_ID,
+ role: 'user',
+ content: 'needs funds',
+ createdAt: 1,
+ })
+ );
+ await expect(
+ flushNextPendingSessionMessage({
+ storage,
+ now: 10,
+ getDeliveryContext: async () => createContext(),
+ validateModeAgainstRuntimeAgents: () => null,
+ deliver: async () => ({
+ success: false,
+ code: 'PAYMENT_REQUIRED',
+ error: 'Insufficient credits',
+ }),
+ })
+ ).resolves.toMatchObject({ type: 'failure', exhausted: true, attempts: 1 });
+ });
+
it('schedules a retry from the time a delivery failure is observed', async () => {
const storage = createMemoryStorage();
await storePendingSessionMessage(
diff --git a/services/cloud-agent-next/src/session/session-message-queue.ts b/services/cloud-agent-next/src/session/session-message-queue.ts
index 060674b390..b532ee9071 100644
--- a/services/cloud-agent-next/src/session/session-message-queue.ts
+++ b/services/cloud-agent-next/src/session/session-message-queue.ts
@@ -262,6 +262,8 @@ function classifyDeliveryFailure(code: PendingFlushFailureCode | undefined): {
case 'MODEL_MISSING':
return { failureStage: 'pre_dispatch', failureCode: 'model_missing' };
case 'WRAPPER_CLEANUP_EXHAUSTED':
+ case 'COMPUTE_STOPPING':
+ case 'BILLING_UNAVAILABLE':
case 'SANDBOX_CAPABILITY_UNAVAILABLE':
case 'WRAPPER_FINALIZING':
case 'INTERNAL':
diff --git a/services/cloud-agent-next/src/terminal/access.test.ts b/services/cloud-agent-next/src/terminal/access.test.ts
index e01fd6de22..b55c3d91ee 100644
--- a/services/cloud-agent-next/src/terminal/access.test.ts
+++ b/services/cloud-agent-next/src/terminal/access.test.ts
@@ -101,6 +101,7 @@ function sandboxWithTerminalResult(
return {
ensureBillingAdmission: vi.fn().mockResolvedValue({ success: true }),
isBillingBlocked: vi.fn().mockResolvedValue(false),
+ getBillingRuntimeStatus: vi.fn().mockResolvedValue(undefined),
ensureWrapper: vi.fn(),
discoverSessionWrappers: vi.fn(),
observeWrappersWithoutWaking: vi.fn(),
diff --git a/services/cloud-agent-next/src/trpc-error.test.ts b/services/cloud-agent-next/src/trpc-error.test.ts
index 174f803e6d..f1c6b1183b 100644
--- a/services/cloud-agent-next/src/trpc-error.test.ts
+++ b/services/cloud-agent-next/src/trpc-error.test.ts
@@ -4,6 +4,28 @@ import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
import { buildTrpcErrorResponse, createClientError, projectTrpcErrorData } from './trpc-error.js';
describe('createClientError', () => {
+ it.each([
+ ['PAYMENT_REQUIRED', 402, 'INSUFFICIENT_CREDITS'],
+ ['CONFLICT', 409, 'COMPUTE_STOPPING'],
+ ['SERVICE_UNAVAILABLE', 503, 'BILLING_UNAVAILABLE'],
+ ])('projects validated %s billing failure at HTTP %i', (code, httpStatus, billingCode) => {
+ const billingFailure = {
+ code: billingCode,
+ payer: { type: 'user', id: 'user-1' },
+ retryable: code !== 'PAYMENT_REQUIRED',
+ };
+ expect(
+ projectTrpcErrorData({ code, httpStatus }, 'Safe message', { billingFailure })
+ ).toMatchObject({ billingFailure });
+ });
+
+ it('omits malformed billing failures', () => {
+ expect(
+ projectTrpcErrorData({ code: 'PAYMENT_REQUIRED', httpStatus: 402 }, 'Safe', {
+ billingFailure: { code: 'INSUFFICIENT_CREDITS' },
+ })
+ ).not.toHaveProperty('billingFailure');
+ });
it.each([
'PARSE_ERROR',
'BAD_REQUEST',
diff --git a/services/cloud-agent-next/src/trpc-error.ts b/services/cloud-agent-next/src/trpc-error.ts
index 10fa69f42c..7f9ebcc4fa 100644
--- a/services/cloud-agent-next/src/trpc-error.ts
+++ b/services/cloud-agent-next/src/trpc-error.ts
@@ -1,5 +1,17 @@
import { PublicErrorCodeSchema, type ClientError } from '@kilocode/worker-utils/client-error';
import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
+import { z } from 'zod';
+
+// Keep aligned with packages/cloud-agent-sdk/src/schemas.ts.
+const customerBillingFailureSchema = z
+ .object({
+ code: z.enum(['INSUFFICIENT_CREDITS', 'COMPUTE_STOPPING', 'BILLING_UNAVAILABLE']),
+ payer: z.object({ type: z.enum(['user', 'org']), id: z.string().min(1) }).strict(),
+ retryable: z.boolean(),
+ remainingMicrodollars: z.number().int().nonnegative().optional(),
+ minimumRequiredMicrodollars: z.number().int().nonnegative().optional(),
+ })
+ .strict();
const NON_RETRYABLE_CODES = new Set([
'PARSE_ERROR',
@@ -61,6 +73,12 @@ function parseExplicitLegacyCause(cause: unknown): ExplicitLegacyCause | undefin
return { error: parsedError.data, retryable: cause.retryable };
}
+function parseBillingFailure(cause: unknown) {
+ if (!cause || typeof cause !== 'object' || !('billingFailure' in cause)) return undefined;
+ const parsed = customerBillingFailureSchema.safeParse(cause.billingFailure);
+ return parsed.success ? parsed.data : undefined;
+}
+
export function createClientError(code: string, message: string, retryable?: boolean): ClientError {
return {
code,
@@ -75,6 +93,7 @@ export function projectTrpcErrorData(
cause?: unknown
): TrpcErrorData & { clientError: ClientError } {
const explicitCause = parseExplicitLegacyCause(cause);
+ const billingFailure = parseBillingFailure(cause);
if (explicitCause) {
const clientError = createClientError(explicitCause.error, message, explicitCause.retryable);
return {
@@ -82,11 +101,13 @@ export function projectTrpcErrorData(
error: explicitCause.error,
retryable: clientError.retryable,
clientError,
+ ...(billingFailure ? { billingFailure } : {}),
};
}
return {
...data,
clientError: createClientError(data.code, message),
+ ...(billingFailure ? { billingFailure } : {}),
};
}
diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts
index f6833a3cd2..95827d6c23 100644
--- a/services/cloud-agent-next/src/types.ts
+++ b/services/cloud-agent-next/src/types.ts
@@ -500,6 +500,7 @@ export type Env = {
CLOUD_AGENT_CONTAINER_BILLING_ENABLED?: string;
CLOUD_AGENT_CONTAINER_BILLING_USER_IDS?: string;
CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS?: string;
+ CONTAINER_BILLING_HEARTBEAT_SECONDS?: string;
Sandbox: DurableObjectNamespace;
/** Durable Object namespace for shared sandbox containers with SCM credential containment */
SandboxContainment: DurableObjectNamespace;
diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts
index 1fbb28e6fa..7326dc3258 100644
--- a/services/cloud-agent-next/worker-configuration.d.ts
+++ b/services/cloud-agent-next/worker-configuration.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: 49b6a02c34a9c5aafad101018d68a38e)
+// Generated by Wrangler by running `wrangler types` (hash: 0f61912537b1887473372e4dd99f2737)
// Runtime types generated with workerd@1.20260714.1 2026-06-03 nodejs_compat
interface __BaseEnv_Env {
SHARED_SANDBOX_OVERRIDES: KVNamespace;
@@ -26,6 +26,7 @@ interface __BaseEnv_Env {
KILOCODE_TOKEN_CONTAINMENT_ORG_IDS?: "";
REPO_SNAPSHOT_ORG_IDS?: "";
TOOL_CGROUP_ORG_IDS: "" | "*";
+ CONTAINER_BILLING_HEARTBEAT_SECONDS: "60" | "300";
NEXTAUTH_SECRET: string;
INTERNAL_API_SECRET: string;
KILOCODE_BACKEND_BASE_URL: string;
@@ -91,6 +92,7 @@ declare namespace Cloudflare {
KILOCODE_TOKEN_CONTAINMENT_ORG_IDS: "";
REPO_SNAPSHOT_ORG_IDS: "";
TOOL_CGROUP_ORG_IDS: "";
+ CONTAINER_BILLING_HEARTBEAT_SECONDS: "60";
NEXTAUTH_SECRET: string;
INTERNAL_API_SECRET: string;
KILOCODE_BACKEND_BASE_URL: string;
@@ -130,7 +132,7 @@ type StringifyValues> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
- interface ProcessEnv extends StringifyValues> {}
+ interface ProcessEnv extends StringifyValues> {}
}
declare module "*.sql" {
const value: string;
diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc
index cc949a42c0..c26004add7 100644
--- a/services/cloud-agent-next/wrangler.jsonc
+++ b/services/cloud-agent-next/wrangler.jsonc
@@ -64,6 +64,7 @@
"CLOUD_AGENT_CONTAINER_BILLING_ENABLED": "true",
"CLOUD_AGENT_CONTAINER_BILLING_USER_IDS": "f1a848ca-bade-48d8-a5ad-1042d08651e6",
"CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS": "",
+ "CONTAINER_BILLING_HEARTBEAT_SECONDS": "300",
},
"placement": { "mode": "smart" },
/**
@@ -432,6 +433,7 @@
"KILOCODE_TOKEN_CONTAINMENT_ORG_IDS": "",
"REPO_SNAPSHOT_ORG_IDS": "",
"TOOL_CGROUP_ORG_IDS": "",
+ "CONTAINER_BILLING_HEARTBEAT_SECONDS": "60",
},
"services": [
{