@opengeni/worker-bundle 1.3.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/activities/agent-turn/errors.d.ts +1 -13
  2. package/dist/activities/agent-turn/sandbox-provision.d.ts +22 -0
  3. package/dist/activities/agent-turn/subscription-lease.d.ts +1 -0
  4. package/dist/activities/agent-turn/tool-policy.d.ts +10 -1
  5. package/dist/activities/agent-turn/turn-context.d.ts +6 -0
  6. package/dist/activities/agent-turn/web-search.d.ts +37 -0
  7. package/dist/activities/codemode-dispatcher.d.ts +8 -0
  8. package/dist/activities/sandbox-lease.d.ts +4 -0
  9. package/dist/{activities-control-PEXBWKQJ.js → activities-control-AKJZHDTY.js} +53 -13
  10. package/dist/activities-control-AKJZHDTY.js.map +1 -0
  11. package/dist/{activities-turn-W27A2SBO.js → activities-turn-AOXNE6BM.js} +5984 -5571
  12. package/dist/activities-turn-AOXNE6BM.js.map +1 -0
  13. package/dist/{chunk-XONQEVAD.js → chunk-DCQ7XTXW.js} +108 -3
  14. package/dist/chunk-DCQ7XTXW.js.map +1 -0
  15. package/dist/{chunk-QUX5LT2B.js → chunk-WVZQJBGD.js} +166 -45
  16. package/dist/chunk-WVZQJBGD.js.map +1 -0
  17. package/dist/index.js +2 -2
  18. package/dist/observability-metrics.d.ts +51 -13
  19. package/dist/sandbox-resume.d.ts +3 -0
  20. package/package.json +21 -21
  21. package/src/activities/agent-turn/agent-build.ts +4 -2
  22. package/src/activities/agent-turn/errors.ts +89 -10
  23. package/src/activities/agent-turn/finalization.ts +16 -0
  24. package/src/activities/agent-turn/governance-model.ts +8 -8
  25. package/src/activities/agent-turn/run.ts +12 -0
  26. package/src/activities/agent-turn/sandbox-establish.ts +87 -80
  27. package/src/activities/agent-turn/sandbox-provision.ts +51 -0
  28. package/src/activities/agent-turn/sandbox-runtime.ts +16 -0
  29. package/src/activities/agent-turn/stream-attempt.ts +36 -15
  30. package/src/activities/agent-turn/subscription-lease.ts +10 -1
  31. package/src/activities/agent-turn/tool-environment.ts +19 -0
  32. package/src/activities/agent-turn/tool-policy.ts +25 -0
  33. package/src/activities/agent-turn/turn-context.ts +8 -0
  34. package/src/activities/agent-turn/web-search.ts +381 -0
  35. package/src/activities/codemode-dispatcher.ts +57 -8
  36. package/src/activities/connector-attachments.ts +3 -1
  37. package/src/activities/sandbox-lease.ts +63 -10
  38. package/src/activity-services.ts +8 -0
  39. package/src/observability-metrics.ts +130 -17
  40. package/src/sandbox-resume.ts +24 -5
  41. package/src/sandbox-routing.ts +70 -24
  42. package/dist/activities-control-PEXBWKQJ.js.map +0 -1
  43. package/dist/activities-turn-W27A2SBO.js.map +0 -1
  44. package/dist/chunk-QUX5LT2B.js.map +0 -1
  45. package/dist/chunk-XONQEVAD.js.map +0 -1
@@ -0,0 +1,381 @@
1
+ import type { AttemptToolDefinition } from "@opengeni/codemode";
2
+ import {
3
+ WEB_FETCH_TOOL_NAME,
4
+ WEB_SEARCH_TOOL_NAME,
5
+ webSearchCallPricing,
6
+ webSearchCreditMicros,
7
+ webSearchProviderConfig,
8
+ webSearchToolPlan,
9
+ type WebSearchProviderConfig,
10
+ type WebSearchProviderToolName,
11
+ type WebSearchSettings,
12
+ } from "@opengeni/config";
13
+ import {
14
+ WebSearchBillingRefusedError,
15
+ type WebSearchBilling,
16
+ type WebSearchCallCost,
17
+ type WebSearchCallScope,
18
+ } from "@opengeni/core";
19
+ import type { Observability } from "@opengeni/observability";
20
+ import {
21
+ WEB_FETCH_TOOL_DESCRIPTION,
22
+ WEB_FETCH_RETAINED_MAX_CHARS,
23
+ WEB_SEARCH_TOOL_DESCRIPTION,
24
+ WebSearchProviderError,
25
+ WebToolArgumentError,
26
+ createWebFetchProvider,
27
+ createWebSearchProvider,
28
+ parseWebFetchArguments,
29
+ parseWebSearchArguments,
30
+ renderWebPageWindow,
31
+ renderWebSearchResults,
32
+ webFetchInputSchema,
33
+ webSearchInputSchema,
34
+ type WebPage,
35
+ } from "@opengeni/runtime/web-search";
36
+ import { hostedWebSearchForTurn } from "./tool-policy";
37
+
38
+ /**
39
+ * Which web search one turn gets: the provider's hosted tool, Opengeni's
40
+ * provider tools, or neither. Tool preparation and agent construction both
41
+ * call this with the same accepted model and settings, so they agree.
42
+ */
43
+ export function turnWebSearchPlan(
44
+ resolvedModel: {
45
+ configured: { hostedWebSearch: boolean };
46
+ provider: { kind?: string };
47
+ } | null,
48
+ settings: WebSearchSettings,
49
+ ) {
50
+ return webSearchToolPlan(settings, {
51
+ hostedWebSearch: hostedWebSearchForTurn(resolvedModel, settings.webSearchEnabled),
52
+ // SuperGrok's native search is added by its transport, not as an agent
53
+ // tool, so provider tools would only duplicate it.
54
+ transportHostedSearch: resolvedModel?.provider.kind === "xai-subscription",
55
+ });
56
+ }
57
+
58
+ /** Pages kept per attempt so paging through one page is not billed again. */
59
+ const PAGE_CACHE_ENTRIES = 16;
60
+
61
+ function textResult(text: string, isError: boolean) {
62
+ return { isError, content: [{ type: "text" as const, text }] };
63
+ }
64
+
65
+ type Pricing = ReturnType<typeof webSearchCallPricing>;
66
+
67
+ function settledCost(
68
+ operationId: string,
69
+ operation: "search" | "fetch",
70
+ provider: string,
71
+ pricing: Pricing,
72
+ reportedCostMicros: number | undefined,
73
+ ): WebSearchCallCost {
74
+ // An explicit operator price wins; then the provider's own reported cost;
75
+ // then the built-in list price.
76
+ const basis: WebSearchCallCost["basis"] = pricing.explicit
77
+ ? "configured_price"
78
+ : reportedCostMicros !== undefined
79
+ ? "provider_reported"
80
+ : "list_price";
81
+ const providerMicros =
82
+ basis === "provider_reported" ? reportedCostMicros! : pricing.providerMicros;
83
+ return {
84
+ operationId,
85
+ operation,
86
+ provider,
87
+ providerMicros,
88
+ creditMicros: webSearchCreditMicros(providerMicros, pricing.marginBps),
89
+ marginBps: pricing.marginBps,
90
+ basis,
91
+ };
92
+ }
93
+
94
+ function providerFailure(error: WebSearchProviderError): string {
95
+ return `${error.message}${error.retryable ? ". You may retry shortly." : "."}`;
96
+ }
97
+
98
+ /** Closed outcome set for `opengeni_web_search_calls_total`. */
99
+ export type WebSearchCallOutcome =
100
+ | "ok"
101
+ | "provider_error"
102
+ | "provider_retryable"
103
+ | "billing_refused"
104
+ | "error";
105
+
106
+ type WebSearchObservability = Pick<Observability, "warn" | "incrementCounter" | "observeHistogram">;
107
+
108
+ const WEB_SEARCH_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 4, 8, 15, 30];
109
+
110
+ /**
111
+ * One provider call: a counter by fixed outcome plus its latency. Labels are
112
+ * the operation, the configured provider id, and the outcome only, never the
113
+ * query, URL, or session.
114
+ */
115
+ function recordWebSearchCall(
116
+ observability: WebSearchObservability,
117
+ operation: "search" | "fetch",
118
+ provider: string,
119
+ outcome: WebSearchCallOutcome,
120
+ startedAt: number,
121
+ ): void {
122
+ observability.incrementCounter({
123
+ name: "opengeni_web_search_calls_total",
124
+ help: "Provider web search/fetch calls made by the worker, by operation, provider and outcome.",
125
+ labels: { operation, provider, outcome },
126
+ });
127
+ observability.observeHistogram({
128
+ name: "opengeni_web_search_call_duration_seconds",
129
+ help: "Provider web search/fetch call duration in seconds.",
130
+ buckets: WEB_SEARCH_DURATION_BUCKETS,
131
+ value: (performance.now() - startedAt) / 1000,
132
+ labels: { operation, provider },
133
+ });
134
+ }
135
+
136
+ function providerFailureOutcome(error: WebSearchProviderError): WebSearchCallOutcome {
137
+ return error.retryable ? "provider_retryable" : "provider_error";
138
+ }
139
+
140
+ /**
141
+ * The `web_search` and `web_fetch` attempt tools for one turn. The caller
142
+ * decides which names the turn gets ({@link webSearchToolPlan}); this only
143
+ * builds them. Provider keys stay in the worker and never reach a sandbox.
144
+ */
145
+ export function webSearchToolDefinitions(input: {
146
+ settings: WebSearchSettings;
147
+ tools: readonly WebSearchProviderToolName[];
148
+ scope: WebSearchCallScope;
149
+ billing: WebSearchBilling;
150
+ observability: WebSearchObservability;
151
+ fetch?: typeof fetch;
152
+ }): AttemptToolDefinition[] {
153
+ const config: WebSearchProviderConfig | null = webSearchProviderConfig(input.settings);
154
+ if (!config || input.tools.length === 0) return [];
155
+ const adapterInput = {
156
+ timeoutMs: config.timeoutMs,
157
+ ...(input.fetch ? { fetch: input.fetch } : {}),
158
+ };
159
+ const settle = async (cost: WebSearchCallCost) => {
160
+ try {
161
+ await input.billing.settle(input.scope, cost);
162
+ } catch (error) {
163
+ // The provider already answered. Keep the model's result; the missing
164
+ // receipt is an operator-visible accounting fault, never a tool failure.
165
+ input.observability.warn("web search usage settlement failed", {
166
+ operation: cost.operation,
167
+ provider: cost.provider,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ });
170
+ }
171
+ };
172
+ const definitions: AttemptToolDefinition[] = [];
173
+
174
+ if (input.tools.includes(WEB_SEARCH_TOOL_NAME)) {
175
+ const provider = createWebSearchProvider({
176
+ ...adapterInput,
177
+ endpoint: config.search,
178
+ });
179
+ const pricing = webSearchCallPricing(config, "search");
180
+ definitions.push({
181
+ identity: { serverId: "opengeni", toolName: WEB_SEARCH_TOOL_NAME },
182
+ modelName: WEB_SEARCH_TOOL_NAME,
183
+ codemodePath: ["opengeni", WEB_SEARCH_TOOL_NAME],
184
+ title: "Search the web",
185
+ description: WEB_SEARCH_TOOL_DESCRIPTION,
186
+ inputSchema: webSearchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
187
+ annotations: {
188
+ title: "Search the web",
189
+ readOnlyHint: true,
190
+ destructiveHint: false,
191
+ idempotentHint: true,
192
+ openWorldHint: true,
193
+ },
194
+ source: "opengeni",
195
+ approval: "none",
196
+ execute: async (args, context) => {
197
+ let request: ReturnType<typeof parseWebSearchArguments>;
198
+ try {
199
+ request = parseWebSearchArguments(args);
200
+ await input.billing.admit(input.scope, pricing.providerMicros);
201
+ } catch (error) {
202
+ if (error instanceof WebSearchBillingRefusedError) {
203
+ recordWebSearchCall(
204
+ input.observability,
205
+ "search",
206
+ config.search.provider,
207
+ "billing_refused",
208
+ performance.now(),
209
+ );
210
+ }
211
+ if (
212
+ error instanceof WebToolArgumentError ||
213
+ error instanceof WebSearchBillingRefusedError
214
+ )
215
+ return textResult(error.message, true);
216
+ throw error;
217
+ }
218
+ const startedAt = performance.now();
219
+ try {
220
+ const response = await provider.search(request, {
221
+ signal: context.signal,
222
+ });
223
+ recordWebSearchCall(
224
+ input.observability,
225
+ "search",
226
+ config.search.provider,
227
+ "ok",
228
+ startedAt,
229
+ );
230
+ await settle(
231
+ settledCost(
232
+ context.operationId,
233
+ "search",
234
+ config.search.provider,
235
+ pricing,
236
+ response.reportedCostMicros,
237
+ ),
238
+ );
239
+ return textResult(renderWebSearchResults(request.query, response.results), false);
240
+ } catch (error) {
241
+ if (context.signal?.aborted) throw error;
242
+ if (error instanceof WebSearchProviderError) {
243
+ recordWebSearchCall(
244
+ input.observability,
245
+ "search",
246
+ config.search.provider,
247
+ providerFailureOutcome(error),
248
+ startedAt,
249
+ );
250
+ input.observability.warn("web search provider call failed", {
251
+ operation: "search",
252
+ provider: config.search.provider,
253
+ status: error.status ?? undefined,
254
+ retryable: error.retryable,
255
+ error: error.message,
256
+ });
257
+ return textResult(`Web search failed: ${providerFailure(error)}`, true);
258
+ }
259
+ recordWebSearchCall(
260
+ input.observability,
261
+ "search",
262
+ config.search.provider,
263
+ "error",
264
+ startedAt,
265
+ );
266
+ throw error;
267
+ }
268
+ },
269
+ });
270
+ }
271
+
272
+ if (input.tools.includes(WEB_FETCH_TOOL_NAME) && config.fetch) {
273
+ const fetchEndpoint = config.fetch;
274
+ const provider = createWebFetchProvider({
275
+ ...adapterInput,
276
+ endpoint: fetchEndpoint,
277
+ });
278
+ const pricing = webSearchCallPricing(config, "fetch");
279
+ const pages = new Map<string, WebPage>();
280
+ definitions.push({
281
+ identity: { serverId: "opengeni", toolName: WEB_FETCH_TOOL_NAME },
282
+ modelName: WEB_FETCH_TOOL_NAME,
283
+ codemodePath: ["opengeni", WEB_FETCH_TOOL_NAME],
284
+ title: "Read a web page",
285
+ description: WEB_FETCH_TOOL_DESCRIPTION,
286
+ inputSchema: webFetchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
287
+ annotations: {
288
+ title: "Read a web page",
289
+ readOnlyHint: true,
290
+ destructiveHint: false,
291
+ idempotentHint: true,
292
+ openWorldHint: true,
293
+ },
294
+ source: "opengeni",
295
+ approval: "none",
296
+ execute: async (args, context) => {
297
+ let request: ReturnType<typeof parseWebFetchArguments>;
298
+ try {
299
+ request = parseWebFetchArguments(args);
300
+ } catch (error) {
301
+ if (error instanceof WebToolArgumentError) return textResult(error.message, true);
302
+ throw error;
303
+ }
304
+ const cached = pages.get(request.url);
305
+ if (cached) return textResult(renderWebPageWindow(cached, request), false);
306
+ try {
307
+ await input.billing.admit(input.scope, pricing.providerMicros);
308
+ } catch (error) {
309
+ if (error instanceof WebSearchBillingRefusedError) {
310
+ recordWebSearchCall(
311
+ input.observability,
312
+ "fetch",
313
+ fetchEndpoint.provider,
314
+ "billing_refused",
315
+ performance.now(),
316
+ );
317
+ return textResult(error.message, true);
318
+ }
319
+ throw error;
320
+ }
321
+ const startedAt = performance.now();
322
+ try {
323
+ const page = await provider.fetch({ url: request.url }, { signal: context.signal });
324
+ recordWebSearchCall(
325
+ input.observability,
326
+ "fetch",
327
+ fetchEndpoint.provider,
328
+ "ok",
329
+ startedAt,
330
+ );
331
+ await settle(
332
+ settledCost(
333
+ context.operationId,
334
+ "fetch",
335
+ fetchEndpoint.provider,
336
+ pricing,
337
+ page.reportedCostMicros,
338
+ ),
339
+ );
340
+ if (pages.size >= PAGE_CACHE_ENTRIES) {
341
+ pages.delete(pages.keys().next().value!);
342
+ }
343
+ // Bound per-attempt memory; one extra character keeps the cut visible.
344
+ pages.set(request.url, {
345
+ ...page,
346
+ content: page.content.slice(0, WEB_FETCH_RETAINED_MAX_CHARS + 1),
347
+ });
348
+ return textResult(renderWebPageWindow(page, request), false);
349
+ } catch (error) {
350
+ if (context.signal?.aborted) throw error;
351
+ if (error instanceof WebSearchProviderError) {
352
+ recordWebSearchCall(
353
+ input.observability,
354
+ "fetch",
355
+ fetchEndpoint.provider,
356
+ providerFailureOutcome(error),
357
+ startedAt,
358
+ );
359
+ input.observability.warn("web search provider call failed", {
360
+ operation: "fetch",
361
+ provider: fetchEndpoint.provider,
362
+ status: error.status ?? undefined,
363
+ retryable: error.retryable,
364
+ error: error.message,
365
+ });
366
+ return textResult(`Web fetch failed: ${providerFailure(error)}`, true);
367
+ }
368
+ recordWebSearchCall(
369
+ input.observability,
370
+ "fetch",
371
+ fetchEndpoint.provider,
372
+ "error",
373
+ startedAt,
374
+ );
375
+ throw error;
376
+ }
377
+ },
378
+ });
379
+ }
380
+ return definitions;
381
+ }
@@ -61,6 +61,12 @@ export type CodemodeDispatcherTimings = {
61
61
  claimHeartbeatMs?: number;
62
62
  };
63
63
 
64
+ const RESUMABLE_STATES: readonly CodemodeOperation["state"][] = [
65
+ "waiting_for_approval",
66
+ "queued",
67
+ "running",
68
+ ];
69
+
64
70
  /**
65
71
  * Owns the only Codemode execution edge for one exact attempt. NATS carries a
66
72
  * wake-up only; the durable row is claimed before any side effect and the call
@@ -190,11 +196,20 @@ export class CodemodeAttemptDispatcher {
190
196
  );
191
197
  }
192
198
 
193
- async resumeApproved(
199
+ /**
200
+ * Join-on-use recovery before the first model request. The journal read needs
201
+ * no tool environment, so a turn with nothing unfinished returns at once and
202
+ * never waits on lazy MCP preparation. Only a stored waiting/queued/running
203
+ * operation awaits the prepared dispatcher to adopt and execute it.
204
+ */
205
+ static async resumeApproved(
206
+ db: Database,
207
+ scope: CodemodeDispatcherScope,
194
208
  callerSubjectId: string,
195
- decisionOperationId?: string,
209
+ decisionOperationId: string | undefined,
210
+ prepared: () => Promise<CodemodeAttemptDispatcher | null>,
196
211
  ): Promise<CodemodeOperation[]> {
197
- const operations = await listTurnCodemodeApprovals(this.db, this.scope);
212
+ const operations = await listTurnCodemodeApprovals(db, scope);
198
213
  if (
199
214
  decisionOperationId &&
200
215
  // Model-tool approval IDs are opaque strings. Only programmatic UUIDs
@@ -202,15 +217,38 @@ export class CodemodeAttemptDispatcher {
202
217
  CodemodeOperation.shape.operationId.safeParse(decisionOperationId).success &&
203
218
  !operations.some((operation) => operation.operationId === decisionOperationId)
204
219
  ) {
205
- const decided = await readTurnCodemodeOperation(this.db, {
206
- ...this.scope,
220
+ const decided = await readTurnCodemodeOperation(db, {
221
+ ...scope,
207
222
  callerSubjectId,
208
223
  operationId: decisionOperationId,
209
224
  });
210
225
  if (decided) operations.push(decided);
211
226
  }
227
+ if (!operations.some((operation) => RESUMABLE_STATES.includes(operation.state))) {
228
+ return operations;
229
+ }
230
+ return (await (await prepared())?.resumeOperations(operations, callerSubjectId)) ?? [];
231
+ }
232
+
233
+ async resumeApproved(
234
+ callerSubjectId: string,
235
+ decisionOperationId?: string,
236
+ ): Promise<CodemodeOperation[]> {
237
+ return await CodemodeAttemptDispatcher.resumeApproved(
238
+ this.db,
239
+ this.scope,
240
+ callerSubjectId,
241
+ decisionOperationId,
242
+ async () => this,
243
+ );
244
+ }
245
+
246
+ private async resumeOperations(
247
+ operations: CodemodeOperation[],
248
+ callerSubjectId: string,
249
+ ): Promise<CodemodeOperation[]> {
212
250
  for (const operation of operations) {
213
- if (!["waiting_for_approval", "queued", "running"].includes(operation.state)) continue;
251
+ if (!RESUMABLE_STATES.includes(operation.state)) continue;
214
252
  let effectDigest: string | null = null;
215
253
  try {
216
254
  effectDigest = this.environment.effectDigest(operation.identity);
@@ -380,7 +418,17 @@ export class CodemodeAttemptDispatcher {
380
418
  try {
381
419
  if (operation.durableApproval) {
382
420
  if (!this.approvalWait) throw new Error("Durable approval yield is unavailable");
383
- releaseWait = this.approvalWait.beginWait();
421
+ // The wait gate is sealed while a model request is in flight or the
422
+ // stream is settling. A call arriving then cannot durably wait, but it
423
+ // must not fail outright: prepare it like a non-waiting client, so an
424
+ // allowed call still executes and an Ask settles approval_required.
425
+ try {
426
+ releaseWait = this.approvalWait.beginWait();
427
+ } catch {
428
+ releaseWait = undefined;
429
+ }
430
+ }
431
+ if (releaseWait) {
384
432
  if (
385
433
  !(await bindCodemodePreparation(this.db, {
386
434
  ...this.scope,
@@ -407,10 +455,11 @@ export class CodemodeAttemptDispatcher {
407
455
  },
408
456
  {
409
457
  signal,
410
- ...(operation.durableApproval ? { transportMeta: { durableApproval: true } } : {}),
458
+ ...(releaseWait ? { transportMeta: { durableApproval: true } } : {}),
411
459
  },
412
460
  );
413
461
  if (preparedCall.waitingForApproval) {
462
+ if (!releaseWait) throw new AttemptToolApprovalRequiredError();
414
463
  const waiting = await waitForCodemodeApproval(this.db, {
415
464
  ...this.scope,
416
465
  operationId: operation.operationId,
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  ChannelAPartialMutationError,
14
14
  RoutingMutationOutcomeUnknownError,
15
+ isRoutingMutationOutputRejectedError,
15
16
  SandboxChannelAService,
16
17
  } from "@opengeni/runtime/sandbox";
17
18
 
@@ -159,7 +160,8 @@ export async function materializeConnectorAttachmentsInChannel(
159
160
  } catch (error) {
160
161
  if (
161
162
  error instanceof ChannelAPartialMutationError ||
162
- error instanceof RoutingMutationOutcomeUnknownError
163
+ error instanceof RoutingMutationOutcomeUnknownError ||
164
+ isRoutingMutationOutputRejectedError(error)
163
165
  ) {
164
166
  throw error;
165
167
  }
@@ -53,6 +53,7 @@ import {
53
53
  recordRecoveredModalProviderCreate,
54
54
  markWarmBillingStopCutoff,
55
55
  listMeterableWarmLeases,
56
+ countInteractionOnlyWarmLeasesByIdle,
56
57
  listSandboxViewerForceDrainWorkspaceIds,
57
58
  markSandboxCheckpointArtifactDeletePending,
58
59
  persistDrainSnapshot,
@@ -174,6 +175,8 @@ import {
174
175
  type RetainedProcessReconciliationOutcome,
175
176
  type SandboxInventoryProjectionDomain,
176
177
  recordSandboxLeaseGauges,
178
+ recordModalSandboxInventoryGauges,
179
+ recordInteractionOnlyLeaseGauges,
177
180
  recordSandboxOrphansTerminated,
178
181
  recordSandboxCommandContainment,
179
182
  recordSandboxProviderMissingBeforeCapture,
@@ -2688,6 +2691,17 @@ async function refreshQueueLeaseAndCreditGauges(
2688
2691
  );
2689
2692
  },
2690
2693
  ),
2694
+ refreshSandboxInventoryGauge(
2695
+ observability,
2696
+ "interaction_idle",
2697
+ "interaction-only-lease",
2698
+ async () => {
2699
+ recordInteractionOnlyLeaseGauges(
2700
+ observability,
2701
+ await countInteractionOnlyWarmLeasesByIdle(db, await listMeterableWarmLeases(db)),
2702
+ );
2703
+ },
2704
+ ),
2691
2705
  refreshSandboxInventoryGauge(observability, "expired_drains", "expired-draining", async () => {
2692
2706
  recordExpiredDrainingSandboxLeaseGauges(
2693
2707
  observability,
@@ -2876,6 +2890,16 @@ export function modalOrphanTerminationStillEligible(
2876
2890
  if (latest.some((lease) => lease.liveness === "warming" && lease.instanceId === null)) {
2877
2891
  return false;
2878
2892
  }
2893
+ return modalOrphanCandidateStillUnowned(latest, candidate);
2894
+ }
2895
+
2896
+ /** Whether no live lease owns the candidate box by exact instance or by its
2897
+ * attribution tags. Unlike termination eligibility this ignores the pending
2898
+ * create postponement: a postponed orphan is still running without a lease. */
2899
+ export function modalOrphanCandidateStillUnowned(
2900
+ latest: Awaited<ReturnType<typeof listLiveModalSandboxLeaseAttributions>>,
2901
+ candidate: ModalOrphanSweepTermination,
2902
+ ): boolean {
2879
2903
  if (latest.some((lease) => lease.instanceId === candidate.sandboxId)) {
2880
2904
  return false;
2881
2905
  }
@@ -2925,6 +2949,30 @@ async function sweepModalOrphansForConfiguredBackend(
2925
2949
  return modalOrphanTerminationStillEligible(latest, candidate);
2926
2950
  },
2927
2951
  });
2952
+ // Only a complete listing is an inventory; a pass cut short by the
2953
+ // termination budget leaves the previous projection to age out as stale.
2954
+ // Re-read durable ownership after the listing so a lease that started or
2955
+ // finished while the app was being listed is not reported as a leak.
2956
+ if (result.inventory.complete) {
2957
+ try {
2958
+ const latest = await listLiveModalSandboxLeaseAttributions(db);
2959
+ recordModalSandboxInventoryGauges(observability, {
2960
+ running: result.inventory.running,
2961
+ unleased: result.inventory.unterminated.filter((candidate) =>
2962
+ modalOrphanCandidateStillUnowned(latest, candidate),
2963
+ ).length,
2964
+ liveLeaseInstancesMissing: result.inventory.missingLiveLeaseInstanceIds.filter(
2965
+ (instanceId) => latest.some((lease) => lease.instanceId === instanceId),
2966
+ ).length,
2967
+ });
2968
+ recordSandboxInventoryProjectionSuccess(observability, "modal_provider");
2969
+ } catch (error) {
2970
+ recordSandboxInventoryProjectionFailure(observability, "modal_provider");
2971
+ observability.warn("sandbox reaper: Modal inventory ownership re-read failed", {
2972
+ error: error instanceof Error ? error.message : String(error),
2973
+ });
2974
+ }
2975
+ }
2928
2976
  for (const terminated of result.terminated) {
2929
2977
  observability.warn("sandbox reaper: terminated Modal orphan sandbox", {
2930
2978
  sandboxId: terminated.sandboxId,
@@ -3524,15 +3572,18 @@ async function terminateDrainableBox(
3524
3572
  // with draining->cold. Until this succeeds, arrivals remain fenced by that
3525
3573
  // exact claim; a timestamp or a failed provider call can never reopen a box
3526
3574
  // while termination may still be in flight.
3527
- const { wentCold, backgroundCommandEvents } = await confirmDrainCold(db, {
3528
- accountId,
3529
- workspaceId: row.workspaceId,
3530
- sandboxGroupId: row.sandboxGroupId,
3531
- expectedEpoch: row.leaseEpoch,
3532
- ...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
3533
- providerMissingBeforeCapture: providerMissing,
3534
- idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
3535
- });
3575
+ const { wentCold, unpublishedProviderLost, backgroundCommandEvents } = await confirmDrainCold(
3576
+ db,
3577
+ {
3578
+ accountId,
3579
+ workspaceId: row.workspaceId,
3580
+ sandboxGroupId: row.sandboxGroupId,
3581
+ expectedEpoch: row.leaseEpoch,
3582
+ ...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
3583
+ providerMissingBeforeCapture: providerMissing,
3584
+ idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
3585
+ },
3586
+ );
3536
3587
  // The command terminal events and agent inputs are already durable in the
3537
3588
  // cold commit; this is only best-effort live fanout.
3538
3589
  await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
@@ -3545,7 +3596,9 @@ async function terminateDrainableBox(
3545
3596
  // Only the exact successful cold commit counts provider loss. A missing
3546
3597
  // probe, a stale capture, a failed commit, or a retried child is not another
3547
3598
  // observed loss. Keep this outside the best-effort session event writer.
3548
- if (providerMissing) {
3599
+ // An unpublished warming replacement (its creator died before warm
3600
+ // publication) held no workspace; its absence is not a capture loss.
3601
+ if (providerMissing && !unpublishedProviderLost) {
3549
3602
  recordSandboxProviderMissingBeforeCapture(observability, backend);
3550
3603
  }
3551
3604
  if (lease.unobservableCommandDrainIds?.length) {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  codeSearchDeploymentPolicy,
3
+ resolveWebSearchProvider,
3
4
  dbSearchPath,
4
5
  getSettings,
5
6
  resolveNatsControlPlaneAuth,
@@ -52,6 +53,13 @@ export function createSharedActivityServices(
52
53
  // Child, scheduled and automation sessions created here freeze their
53
54
  // code_search decision from the same deployment policy as the API.
54
55
  configureCodeSearchDeploymentPolicy(codeSearchDeploymentPolicy(settings));
56
+ // A named but unusable web search provider withholds the tools; say why.
57
+ const webSearchProvider = resolveWebSearchProvider(settings);
58
+ if (webSearchProvider.status === "invalid") {
59
+ observability.warn("web search provider is misconfigured; web_search stays off", {
60
+ reason: webSearchProvider.reason,
61
+ });
62
+ }
55
63
  return {
56
64
  settings: resolvedSettings,
57
65
  catalogSourceSettings: settings,