Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
584 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1/**
2 * Compute gating: whether a workspace may start something that costs g1t
3 * real money, and with what caps. Every place compute starts (agent runs,
4 * checks, the merge queue, merge checks and catch-ups in the runner,
5 * workflow jobs, deploy builds and context embeddings) asks the billing
6 * service through `ComputeGate.admit` first, and settles what it reserved
7 * when the work ends.
8 *
9 * The billing service owns the decision (`reserve`); this module only
10 * shapes the call, words the refusal, and decides what happens when billing
11 * cannot be reached:
12 *
13 * - **Free workspaces fail closed.** If billing errors, nothing starts:
14 * a free workspace never gets compute because billing was down.
15 * - **Paid, internal and enterprise workspaces fail open.** If billing
16 * errors, the work starts without a reservation and the error is logged,
17 * so a billing blip never stops paying customers. The plan is read from
18 * `entitlements`, or, when that call fails too, from the last plan seen
19 * for the workspace (kept in the isolate and in the Cache API for a day).
20 * A workspace whose plan is not known is treated as free.
21 * - **Internal and enterprise plans always pass.** A refusal from billing
22 * for one is logged, not shown.
23 *
24 * Estimates and settlements are at what the work costs g1t, before the
25 * margin: billing applies the margin as it does to every other meter.
26 *
27 * Only type imports, so services' unit tests can load it on its own.
28 *
29 * Mirrors `entitlements`, `reserve` and `settle` in the billing service
30 * (`g1t_contracts::billing`), which speak camelCase. Answers are read in
31 * either case, so an older or newer billing never reads as no plan.
32 */
33import type { ServiceBinding } from "./clients";
34import type { RepoPath } from "./repos";
35import type { Result } from "./result";
36
37/** What a workspace pays for. */
38export type ComputePlan = "free" | "paid" | "internal" | "enterprise";
39
40/** What kind of compute is being started. */
41export type ComputeKind = "agent" | "check" | "workflow" | "queue" | "deploy" | "embedding";
42
43/** What pays for a reservation. */
44export type PaidBy = "credit" | "trial" | "oss" | "on_demand";
45
46/** Why billing refused a reservation. */
47export type ComputeRefusalCode = "not_paid" | "trial_used" | "limit" | "paused" | "oss_pool_empty";
48
49/**
50 * Codes the gate itself refuses with, besides billing's: `issue_cap` (an
51 * issue's agents spent their cap), `billing_unavailable` (a free workspace
52 * while billing cannot be reached).
53 */
54export type GateRefusalCode = ComputeRefusalCode | "issue_cap" | "billing_unavailable";
55
56/** The compute side of what a workspace's plan gives it. */
57export type ComputeEntitlements = {
58 plan: ComputePlan;
59 /** Whether its plan includes compute at all. */
60 compute: boolean;
61 /** The one-time trial's usage left. */
62 trialMicrosLeft: number;
63 /** Whether the workspace passed the card check the trial and open-source pool need. */
64 trialVerified: boolean;
65 /** A paid workspace in its first month: tighter caps. */
66 firstMonth: boolean;
67 /** Agent runs at once; zero means no cap. */
68 maxConcurrentAgents: number;
69 /** The longest any sandbox may run, in minutes; zero means no cap. */
70 maxRunMinutes: number;
71 /** The most one agent run may cost; zero means no cap. */
72 runCapMicros: number;
73 /** The most the agents on one issue may cost in all; zero means no cap. */
74 issueCapMicros: number;
75 ceilingMicros: number;
76 exposureMicros: number;
77 /** Why g1t paused the workspace's compute; null when it is not paused. */
78 paused: string | null;
79};
80
81export type Reservation = { id: string; paidBy: PaidBy };
82
83/** Compute the open-source pool can pay for, on public repositories. */
84export const OSS_KINDS: ReadonlySet<ComputeKind> = new Set(["check", "workflow", "queue"]);
85
86const REFUSAL_CODES: ReadonlySet<string> = new Set(["not_paid", "trial_used", "limit", "paused", "oss_pool_empty"]);
87
88// ---- Reading billing's answers ----------------------------------------------
89
90type Raw = Record<string, unknown>;
91
92/** A field in camelCase or snake_case. */
93function field(raw: Raw, camel: string): unknown {
94 if (camel in raw) return raw[camel];
95 const snake = camel.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
96 return raw[snake];
97}
98
99const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
100
101const PLANS: ReadonlySet<string> = new Set(["free", "paid", "internal", "enterprise"]);
102
103/**
104 * Billing's `entitlements`, as this module reads them. Null when they do
105 * not say the plan (the billing service has not got the compute contract
106 * yet, or answered something else).
107 */
108export function readEntitlements(answer: unknown): ComputeEntitlements | null {
109 if (!answer || typeof answer !== "object") return null;
110 let raw = answer as Raw;
111 // An outcome, if billing wraps it in one.
112 if ("ok" in raw && ("value" in raw || "error" in raw)) {
113 if (raw.ok !== true || !raw.value || typeof raw.value !== "object") return null;
114 raw = raw.value as Raw;
115 }
116 const plan = field(raw, "plan");
117 if (typeof plan !== "string" || !PLANS.has(plan)) return null;
118 const paused = field(raw, "paused");
119 return {
120 plan: plan as ComputePlan,
121 compute: field(raw, "compute") === true,
122 trialMicrosLeft: num(field(raw, "trialMicrosLeft")),
123 trialVerified: field(raw, "trialVerified") === true,
124 firstMonth: field(raw, "firstMonth") === true,
125 maxConcurrentAgents: num(field(raw, "maxConcurrentAgents")),
126 maxRunMinutes: num(field(raw, "maxRunMinutes")),
127 runCapMicros: num(field(raw, "runCapMicros")),
128 issueCapMicros: num(field(raw, "issueCapMicros")),
129 ceilingMicros: num(field(raw, "ceilingMicros")),
130 exposureMicros: num(field(raw, "exposureMicros")),
131 paused: typeof paused === "string" && paused.trim() ? paused.trim() : null,
132 };
133}
134
135/** A reservation from billing's `reserve`, or null if it is not one. */
136export function readReservation(value: unknown): Reservation | null {
137 if (!value || typeof value !== "object") return null;
138 const raw = value as Raw;
139 const id = field(raw, "id");
140 if (typeof id !== "string" || !id) return null;
141 const paidBy = field(raw, "paidBy");
142 return { id, paidBy: (typeof paidBy === "string" ? paidBy : "credit") as PaidBy };
143}
144
145/**
146 * Billing's refusal code, if a failure is a refusal rather than an error.
147 * Accepts the code itself, a `reason` beside a generic code, and
148 * `payment_required`, which billing has used for refusals before.
149 */
150export function refusalCode(failure: { code?: unknown; reason?: unknown } | null | undefined): ComputeRefusalCode | null {
151 if (!failure) return null;
152 for (const candidate of [failure.code, failure.reason]) {
153 if (typeof candidate === "string" && REFUSAL_CODES.has(candidate)) return candidate as ComputeRefusalCode;
154 }
155 return failure.code === "payment_required" ? "not_paid" : null;
156}
157
158// ---- Who can run what -------------------------------------------------------
159
160/** Plans that never wait on billing. */
161export function alwaysPasses(plan: ComputePlan | null | undefined): boolean {
162 return plan === "internal" || plan === "enterprise";
163}
164
165/** What happens when billing cannot be reached: paying plans go on, the rest stop. */
166export function onBillingError(plan: ComputePlan | null | undefined): "allow" | "refuse" {
167 return plan === "paid" || alwaysPasses(plan) ? "allow" : "refuse";
168}
169
170/**
171 * Whether a workspace can start `kind` at all, from its entitlements alone:
172 * the note people see before they try, and the check for work too small to
173 * reserve one by one (a search query's embedding). Billing's `reserve` is
174 * the decision for everything else. Null when it can; the refusal when not.
175 */
176export function localRefusal(
177 ent: ComputeEntitlements,
178 kind: ComputeKind,
179 isPublic: boolean,
180): ComputeRefusalCode | null {
181 if (ent.paused) return "paused";
182 if (ent.plan !== "free") return null;
183 if (ent.compute) return null;
184 if (ent.trialVerified && ent.trialMicrosLeft > 0) return null;
185 if (isPublic && OSS_KINDS.has(kind) && ent.trialVerified) return null;
186 return ent.trialVerified && ent.trialMicrosLeft <= 0 ? "trial_used" : "not_paid";
187}
188
189/** Where a workspace's owners start the plan or the trial. */
190export function billingPath(workspace: string): string {
191 return `/${workspace.toLowerCase()}/-/billing`;
192}
193
194const WHAT: Record<ComputeKind, string> = {
195 agent: "Agents need",
196 check: "Checks run in g1t's sandboxes, which need",
197 workflow: "Workflows run in g1t's sandboxes, which need",
198 queue: "The merge queue runs in g1t's sandboxes, which needs",
199 deploy: "Deployments need",
200 embedding: "Semantic search needs",
201};
202
203/** What people are told when compute is refused: plain, with what to do and where. */
204export function refusalMessage(
205 code: GateRefusalCode,
206 workspace: string,
207 kind: ComputeKind,
208 detail?: string | null,
209): string {
210 const link = billingPath(workspace);
211 switch (code) {
212 case "not_paid": {
213 const oss = OSS_KINDS.has(kind)
214 ? " Public repositories can use g1t's open-source pool instead, after a card check."
215 : "";
216 return `${WHAT[kind]} a paid workspace.${oss} Start the $20 plan or try it with $5 of free usage after a card check: ${link}`;
217 }
218 case "trial_used":
219 return `This workspace has used its $5 of free usage. Start the $20 plan to keep going: ${link}`;
220 case "limit":
221 return `This workspace reached its spend limit for the month, so nothing new starts. An owner can raise it: ${link}#limit`;
222 case "paused":
223 return `g1t paused compute for this workspace${detail ? `: ${detail.replace(/.$/, "")}` : ""}. Contact support@g1t.sh to have it looked at.`;
224 case "oss_pool_empty":
225 return `g1t's open-source pool is used up for this month, so checks and workflows on public repositories wait until next month. Start the $20 plan to run them now: ${link}`;
226 case "issue_cap":
227 return `${detail ?? "This issue's agents reached its spending cap"}. An owner can raise the cap per issue: ${link}#caps`;
228 case "billing_unavailable":
229 return "g1t could not reach its billing service, so it did not start this. Try again in a minute.";
230 }
231}
232
233/**
234 * The note people see before they try to start `kind`, when their
235 * workspace's plan would refuse it: plain, with where to fix it. Null when
236 * it would go ahead.
237 */
238export function computeNote(
239 ent: ComputeEntitlements,
240 kind: ComputeKind,
241 isPublic: boolean,
242 workspace: string,
243): string | null {
244 const refused = localRefusal(ent, kind, isPublic);
245 if (!refused) return null;
246 const link = billingPath(workspace);
247 if (refused === "paused") return refusalMessage("paused", workspace, kind, ent.paused);
248 if (refused === "trial_used") {
249 return `This workspace has used its free trial. ${WHAT[kind]} a paid workspace: ${link}`;
250 }
251 if (OSS_KINDS.has(kind) && isPublic) {
252 return `${WHAT[kind]} a paid workspace or the free trial. On a public repository, g1t's open-source pool runs them after a card check: ${link}`;
253 }
254 return `${WHAT[kind]} a paid workspace or the free trial: ${link}`;
255}
256
257// ---- Estimates --------------------------------------------------------------
258
259/** The kinds of agent run, as the runner names them. */
260export type AgentRunKind = "implement" | "revise" | "review" | "answer" | "update" | "plan" | "reply";
261
262/**
263 * What the model part of each kind of agent run costs g1t, on average, from
264 * measured runs: implement about $0.094, review $0.07, plan $0.104. The
265 * rest are scaled from those by how much they do.
266 */
267export const MODEL_ESTIMATE_MICROS: Record<AgentRunKind, number> = {
268 implement: 100_000,
269 revise: 100_000,
270 review: 70_000,
271 plan: 100_000,
272 update: 50_000,
273 answer: 30_000,
274 reply: 30_000,
275};
276
277/**
278 * What one second of a sandbox costs g1t, in millionths of a dollar, when
279 * the price book cannot be read: Containers standard-1 with its Durable
280 * Object, rounded up.
281 */
282export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 25;
283
284/** What Workers AI's embedding model costs g1t per token. */
285export const EMBEDDING_MICROS_PER_TOKEN = 0.067;
286
287/** A sandbox for `minutes` at `microsPerSecond`. */
288export function sandboxEstimateMicros(minutes: number, microsPerSecond: number): number {
289 return Math.ceil(Math.max(0, minutes) * 60 * Math.max(0, microsPerSecond));
290}
291
292/**
293 * An agent run: its model's average, unless the workspace's own provider
294 * pays for the model, plus its sandbox for its whole time cap.
295 */
296export function agentEstimateMicros(
297 task: AgentRunKind,
298 minutes: number,
299 microsPerSecond: number,
300 ownModel = false,
301): number {
302 return (ownModel ? 0 : MODEL_ESTIMATE_MICROS[task]) + sandboxEstimateMicros(minutes, microsPerSecond);
303}
304
305/** Embedding `tokens` of text. */
306export function embeddingEstimateMicros(tokens: number): number {
307 return Math.ceil(Math.max(0, tokens) * EMBEDDING_MICROS_PER_TOKEN);
308}
309
310/** What a sandbox that ran `seconds` cost, plus a model's cost in dollars. */
311export function actualMicros(seconds: number, microsPerSecond: number, modelUsd = 0): number {
312 const model = Number.isFinite(modelUsd) && modelUsd > 0 ? modelUsd * 1_000_000 : 0;
313 return Math.ceil(Math.max(0, seconds) * Math.max(0, microsPerSecond) + model);
314}
315
316// ---- Caps ---------------------------------------------------------------------
317
318/** The lower of two caps, where null, zero or less means no cap. */
319export function lowerCap(a: number | null | undefined, b: number | null | undefined): number | null {
320 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
321 return caps.length ? Math.min(...caps) : null;
322}
323
324/** A run's time cap: the guardrails' and the plan's, whichever is lower. */
325export function runMinutes(guardMinutes: number, ent: ComputeEntitlements | null): number {
326 return lowerCap(guardMinutes, ent?.maxRunMinutes) ?? guardMinutes;
327}
328
329/** A run's cost cap in dollars: the guardrails' and the plan's, whichever is lower. */
330export function runBudgetUsd(guardBudgetUsd: number | null, ent: ComputeEntitlements | null): number | null {
331 const planCap = ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null;
332 return lowerCap(guardBudgetUsd, planCap);
333}
334
335// ---- Agents at once ---------------------------------------------------------------
336
337/** How a run waiting for room says so; `isWaiting` recognises it. */
338export const WAITING_PREFIX = "Waiting for a free slot";
339
340/** Whether another agent run fits under the workspace's cap. */
341export function slotFree(activeAgents: number, ent: ComputeEntitlements | null): boolean {
342 if (!ent || ent.maxConcurrentAgents <= 0 || alwaysPasses(ent.plan)) return true;
343 return activeAgents < ent.maxConcurrentAgents;
344}
345
346export function waitingMessage(max: number): string {
347 return `${WAITING_PREFIX}: this workspace runs ${max} ${max === 1 ? "agent" : "agents"} at a time and all are busy. It starts by itself when one finishes.`;
348}
349
350export function isWaiting(message: string | null | undefined): boolean {
351 return typeof message === "string" && message.startsWith(WAITING_PREFIX);
352}
353
354/** Why an issue's agents may not start again: they spent its cap. Null when they may. */
355export function issueCapReached(spentMicros: number, ent: ComputeEntitlements | null, issue: number): string | null {
356 if (!ent || ent.issueCapMicros <= 0 || alwaysPasses(ent.plan)) return null;
357 if (spentMicros < ent.issueCapMicros) return null;
358 const dollars = (micros: number) => `$${(micros / 1_000_000).toFixed(2)}`;
359 return `The agents on #${issue} have spent ${dollars(spentMicros)}, its cap of ${dollars(ent.issueCapMicros)}, so g1t will not start on it again`;
360}
361
362// ---- The gate -------------------------------------------------------------------
363
364/** Where the last plan seen for each workspace is kept, for when billing is down. */
365export interface PlanMemory {
366 get(workspace: string): Promise<ComputePlan | null>;
367 put(workspace: string, plan: ComputePlan): Promise<void>;
368}
369
370/** The part of the Cache API this uses. */
371type PlanCache = {
372 match(key: string): Promise<Response | undefined>;
373 put(key: string, response: Response): Promise<void>;
374};
375
376const PLAN_MEMORY_SECONDS = 24 * 60 * 60;
377const isolatePlans = new Map<string, { plan: ComputePlan; until: number }>();
378
379/**
380 * The last plan seen, in this isolate and in the Cache API (where the
381 * runtime has one), for a day.
382 */
383export const defaultPlanMemory: PlanMemory = {
384 async get(workspace) {
385 const kept = isolatePlans.get(workspace);
386 if (kept && kept.until > Date.now()) return kept.plan;
387 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
388 if (!cache) return null;
389 try {
390 const hit = await cache.match(planKey(workspace));
391 const plan = hit ? await hit.text() : null;
392 return plan && PLANS.has(plan) ? (plan as ComputePlan) : null;
393 } catch {
394 return null;
395 }
396 },
397 async put(workspace, plan) {
398 isolatePlans.set(workspace, { plan, until: Date.now() + PLAN_MEMORY_SECONDS * 1000 });
399 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
400 if (!cache) return;
401 try {
402 await cache.put(
403 planKey(workspace),
404 new Response(plan, { headers: { "cache-control": `max-age=${PLAN_MEMORY_SECONDS}` } }),
405 );
406 } catch {
407 // Remembering is a convenience; the isolate's copy is enough.
408 }
409 },
410};
411
412function planKey(workspace: string): string {
413 return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`;
414}
415
416export type ReserveRequest = {
417 workspace: string;
418 repo: RepoPath;
419 public: boolean;
420 kind: ComputeKind;
421 estimateMicros: number;
422 /** An agent run on g1t's hosted models, which g1t's daily spend breaker can pause. Unsaid, an agent run is taken to be one. */
423 hostedModel?: boolean;
424};
425
426/** The gate's answer: go ahead (with what was reserved, if anything), or why not. */
427export type Admission =
428 | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null }
429 | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null };
430
431const ENTITLEMENTS_SECONDS = 30;
Merge branch 'worktree-agent-a985c01f43538af01'432/**
433 * An answer that holds compute back (paused, or a free workspace without
434 * compute) is kept only a few seconds: the owner who just resumed it or
435 * added a plan presses Run next, and must not be refused by a copy from
436 * before they did.
437 */
438const HOLDING_BACK_SECONDS = 3;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor439const PRICE_SECONDS = 10 * 60;
440
Merge branch 'worktree-agent-a985c01f43538af01'441/** How long the gate keeps an entitlements answer, in seconds. */
442export function entitlementsKeptSeconds(ent: ComputeEntitlements): number {
443 const holdsBack = Boolean(ent.paused) || (ent.plan === "free" && !ent.compute);
444 return holdsBack ? HOLDING_BACK_SECONDS : ENTITLEMENTS_SECONDS;
445}
446
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor447export class ComputeGate {
448 private ents = new Map<string, { value: ComputeEntitlements; until: number }>();
449 private price: { value: number; until: number } | null = null;
450
451 private readonly billing: ServiceBinding;
452 private readonly memory: PlanMemory;
453 private readonly log: (...args: unknown[]) => void;
454
455 // Plain fields, not parameter properties: Node's type stripping, which
456 // the services' tests run under, does not take those.
457 constructor(
458 billing: ServiceBinding,
459 memory: PlanMemory = defaultPlanMemory,
460 log: (...args: unknown[]) => void = console.log,
461 ) {
462 this.billing = billing;
463 this.memory = memory;
464 this.log = log;
465 }
466
467 private async call<T>(method: string, args: object): Promise<T> {
468 const response = await this.billing.fetch(`https://service/rpc/${method}`, {
469 method: "POST",
470 headers: { "content-type": "application/json" },
471 body: JSON.stringify(args),
472 });
473 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
474 return (await response.json()) as T;
475 }
476
477 /**
478 * The workspace's entitlements, kept for half a minute. Null when billing
479 * cannot say; never throws.
480 */
481 async entitlements(workspace: string): Promise<ComputeEntitlements | null> {
482 const slug = workspace.toLowerCase();
483 const kept = this.ents.get(slug);
484 if (kept && kept.until > Date.now()) return kept.value;
485 try {
486 const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug }));
487 if (!ent) throw new Error("entitlements did not say the workspace's plan");
Merge branch 'worktree-agent-a985c01f43538af01'488 this.ents.set(slug, { value: ent, until: Date.now() + entitlementsKeptSeconds(ent) * 1000 });
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor489 await this.memory.put(slug, ent.plan);
490 return ent;
491 } catch (error) {
492 this.log("compute gate: entitlements unavailable", slug, String(error));
493 return null;
494 }
495 }
496
497 /** What one second of a sandbox costs g1t, from the price book. */
498 async microsPerSecond(): Promise<number> {
499 if (this.price && this.price.until > Date.now()) return this.price.value;
500 let value = FALLBACK_SANDBOX_MICROS_PER_SECOND;
501 try {
502 const book = await this.call<{ prices?: { meter: string; costMicros?: number; cost_micros?: number }[] }>("prices", {});
503 const meter = book.prices?.find((price) => price.meter === "sandbox_second");
504 const cost = meter?.costMicros ?? meter?.cost_micros;
505 if (typeof cost === "number" && cost > 0) value = cost;
506 } catch (error) {
507 this.log("compute gate: price book unavailable", String(error));
508 }
509 this.price = { value, until: Date.now() + PRICE_SECONDS * 1000 };
510 return value;
511 }
512
513 /**
514 * Asks billing to reserve what `request` is expected to cost. Refused
515 * when billing refuses, or when billing cannot be reached for a workspace
516 * that is not known to pay (see the module's comment). Never throws.
517 */
518 async admit(request: ReserveRequest, known?: ComputeEntitlements | null): Promise<Admission> {
519 const workspace = request.workspace.toLowerCase();
520 const ent = known === undefined ? await this.entitlements(workspace) : known;
521 const refuse = (code: GateRefusalCode, message?: string | null): Admission => ({
522 ok: false,
523 code,
524 message: message || refusalMessage(code, workspace, request.kind, ent?.paused),
525 entitlements: ent,
526 });
527 if (ent?.paused) return refuse("paused");
528 const plan = ent?.plan ?? (await this.memory.get(workspace));
529 let answer: Result<unknown>;
530 try {
531 answer = await this.call<Result<unknown>>("reserve", {
532 workspace,
533 repo: request.repo,
534 public: request.public,
535 kind: request.kind,
536 estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)),
537 ...(request.hostedModel === undefined ? {} : { hostedModel: request.hostedModel }),
538 });
539 } catch (error) {
540 return this.unavailable(plan, refuse, ent, request, String(error));
541 }
542 if (answer.ok) {
543 const reservation = readReservation(answer.value);
544 if (!reservation) return this.unavailable(plan, refuse, ent, request, "reserve answered without a reservation");
545 return { ok: true, reservation, entitlements: ent };
546 }
547 const code = refusalCode(answer.error as { code?: unknown; reason?: unknown });
548 if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message);
549 // A pause holds for every plan: g1t's own caps (a comped account's
550 // monthly budget, the daily spend breaker) and staff holds included.
551 if (alwaysPasses(plan) && code !== "paused") {
552 this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message);
553 return { ok: true, reservation: null, entitlements: ent };
554 }
555 // Billing's own words when it gave them; they carry the link to act on.
556 return refuse(code, answer.error.message || null);
557 }
558
559 private unavailable(
560 plan: ComputePlan | null,
561 refuse: (code: GateRefusalCode, message?: string | null) => Admission,
562 ent: ComputeEntitlements | null,
563 request: ReserveRequest,
564 why: string,
565 ): Admission {
566 if (onBillingError(plan) === "allow") {
567 this.log("compute gate: billing unavailable; allowed for", plan, request.workspace, request.kind, why);
568 return { ok: true, reservation: null, entitlements: ent };
569 }
570 this.log("compute gate: billing unavailable; refused for", plan ?? "unknown plan", request.workspace, request.kind, why);
571 return refuse("billing_unavailable");
572 }
573
574 /** Settles a reservation at what the work cost. Never throws; a failure is logged. */
575 async settle(reservationId: string, actual: number): Promise<void> {
576 const micros = Math.max(0, Math.ceil(actual));
577 try {
578 const settled = await this.call<Result<unknown>>("settle", { reservationId, actualMicros: micros });
579 if (settled && settled.ok === false) this.log("compute gate: settle refused", reservationId, settled.error.message);
580 } catch (error) {
581 this.log("compute gate: settle failed", reservationId, micros, String(error));
582 }
583 }
584}

This file's history is long; its oldest lines are credited to the oldest commit read.