@opengeni/worker-bundle 1.4.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 (36) hide show
  1. package/dist/activities/agent-turn/sandbox-provision.d.ts +22 -0
  2. package/dist/activities/agent-turn/turn-context.d.ts +6 -0
  3. package/dist/activities/agent-turn/web-search.d.ts +5 -1
  4. package/dist/activities/codemode-dispatcher.d.ts +8 -0
  5. package/dist/activities/programmatic-approvals.d.ts +0 -17
  6. package/dist/activities/sandbox-lease.d.ts +4 -0
  7. package/dist/{activities-control-D5KVEPHI.js → activities-control-AKJZHDTY.js} +53 -13
  8. package/dist/activities-control-AKJZHDTY.js.map +1 -0
  9. package/dist/{activities-turn-4Z3M7Z3F.js → activities-turn-AOXNE6BM.js} +6038 -5890
  10. package/dist/activities-turn-AOXNE6BM.js.map +1 -0
  11. package/dist/{chunk-FF4HPY7L.js → chunk-DCQ7XTXW.js} +108 -3
  12. package/dist/chunk-DCQ7XTXW.js.map +1 -0
  13. package/dist/{chunk-BMTWULF7.js → chunk-WVZQJBGD.js} +55 -17
  14. package/dist/chunk-WVZQJBGD.js.map +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/observability-metrics.d.ts +52 -14
  17. package/dist/sandbox-resume.d.ts +3 -0
  18. package/package.json +21 -21
  19. package/src/activities/agent-turn/errors.ts +7 -7
  20. package/src/activities/agent-turn/finalization.ts +16 -0
  21. package/src/activities/agent-turn/run.ts +12 -0
  22. package/src/activities/agent-turn/sandbox-establish.ts +87 -80
  23. package/src/activities/agent-turn/sandbox-provision.ts +51 -0
  24. package/src/activities/agent-turn/stream-attempt.ts +37 -50
  25. package/src/activities/agent-turn/turn-context.ts +8 -0
  26. package/src/activities/agent-turn/web-search.ts +135 -6
  27. package/src/activities/codemode-dispatcher.ts +57 -8
  28. package/src/activities/programmatic-approvals.ts +0 -26
  29. package/src/activities/sandbox-lease.ts +63 -10
  30. package/src/observability-metrics.ts +130 -18
  31. package/src/sandbox-resume.ts +24 -5
  32. package/src/sandbox-routing.ts +12 -0
  33. package/dist/activities-control-D5KVEPHI.js.map +0 -1
  34. package/dist/activities-turn-4Z3M7Z3F.js.map +0 -1
  35. package/dist/chunk-BMTWULF7.js.map +0 -1
  36. package/dist/chunk-FF4HPY7L.js.map +0 -1
@@ -78,11 +78,8 @@ import {
78
78
  generatedImageFromSdkEvent,
79
79
  isCompletedGeneratedImageSdkEvent,
80
80
  } from "../generated-images";
81
- import {
82
- programmaticApproval,
83
- programmaticContinuationNote,
84
- recoverProgrammaticOperationsBeforeFirstRequest,
85
- } from "../programmatic-approvals";
81
+ import { programmaticApproval, programmaticContinuationNote } from "../programmatic-approvals";
82
+ import { CodemodeAttemptDispatcher } from "../codemode-dispatcher";
86
83
  import { ToolResultSpill } from "./tool-result-spill";
87
84
  import { ownedTurnSandboxForAgent } from "./turn-sandbox-access";
88
85
  import { createTurnCredentialLeases } from "./credential-leases";
@@ -665,6 +662,16 @@ export async function runTurnStreamAttempt(
665
662
  const providerPublishesNativeRequestEvents =
666
663
  resolvedModel?.provider.kind === "codex-subscription" ||
667
664
  resolvedModel?.provider.kind === "xai-subscription";
665
+ // Bounded provider label for the streaming SLIs — the resolved registry
666
+ // provider id (or the built-in OpenAI/Azure provider), never a raw
667
+ // user-supplied model string. Created before the stream starts so the
668
+ // first request's model entry and provider dispatch are observed.
669
+ const streamTiming = new StreamTimingMetrics(observability, {
670
+ provider: streamProvider,
671
+ });
672
+ // Native Codex/SuperGrok transports report their literal dispatch through
673
+ // the shared turn context; generic transports use the wire-dispatch clock.
674
+ eventing.providerDispatchObserver = () => streamTiming.onProviderDispatch();
668
675
  let fallbackProviderRequestStartedAt: number | null = null;
669
676
  let fallbackProviderRequestLifecycleStartedAt: number | null = null;
670
677
  const recordFallbackProviderDispatchAtWire = async (): Promise<void> => {
@@ -857,7 +864,10 @@ export async function runTurnStreamAttempt(
857
864
  }
858
865
  attempt.modelRequestStarted = true;
859
866
  return await runtime.runStream(agent, runInput!, eventing.modelRunSettings, {
860
- beforeModelRequest: modelCallAdmission.beforeModelRequest,
867
+ beforeModelRequest: () => {
868
+ streamTiming.onModelRequestEntry();
869
+ return modelCallAdmission.beforeModelRequest();
870
+ },
861
871
  onModelResponse: (event) => {
862
872
  responseCreditPolicyRevisions.add(creditPolicyRevision);
863
873
  return modelCallAdmission.onModelResponse(event);
@@ -969,6 +979,7 @@ export async function runTurnStreamAttempt(
969
979
  ? {
970
980
  onModelTransportStarted: recordFallbackProviderDispatchAtWire,
971
981
  onModelTransportDispatched: (clock) => {
982
+ streamTiming.onProviderDispatch();
972
983
  eventing.initialModelWireDispatch.record(
973
984
  { provider: streamProvider, dispatchId },
974
985
  clock,
@@ -1010,12 +1021,6 @@ export async function runTurnStreamAttempt(
1010
1021
  modelCallAdmission.close();
1011
1022
  throw error;
1012
1023
  }
1013
- // Bounded provider label for the streaming SLIs — the resolved registry
1014
- // provider id (or the built-in OpenAI/Azure provider), never a raw
1015
- // user-supplied model string.
1016
- const streamTiming = new StreamTimingMetrics(observability, {
1017
- provider: streamProvider,
1018
- });
1019
1024
  eventing.batcher = createRuntimeBatcher(
1020
1025
  async (events) => {
1021
1026
  await eventing.publish!(events);
@@ -1964,44 +1969,26 @@ export async function runTurnStreamAttempt(
1964
1969
 
1965
1970
  // Recover committed waits/results before any model dispatch, including crash replacement.
1966
1971
  // A new attempt uses its own signed caller; origin provenance never changes.
1967
- // Lazy MCP preparation is never awaited unless stored work must resume.
1968
- // Measured as its own startup phase so a first-request barrier cannot hide.
1969
- const programmaticRecoveryStartedAt = performance.now();
1970
- let programmaticRecoveryOutcome: "completed" | "failed" = "failed";
1971
- const codemodeOperations = await recoverProgrammaticOperationsBeforeFirstRequest({
1972
- approvalDecisionId:
1973
- trigger.type === "user.approvalDecision"
1974
- ? (trigger.payload as { approvalId: string }).approvalId
1975
- : undefined,
1976
- hasUnfinishedOperations: async () =>
1977
- (
1978
- await listTurnCodemodeApprovals(db, {
1979
- accountId: input.accountId,
1980
- workspaceId: input.workspaceId,
1981
- sessionId: input.sessionId,
1982
- turnId: activeTurnId,
1983
- })
1984
- ).length > 0,
1985
- toolPreparationReady: eventing.toolPreparationReady,
1986
- resumeApproved: (decisionOperationId) =>
1987
- eventing.codemodeDispatcher?.resumeApproved(
1988
- `sandbox:${input.attemptId}`,
1989
- decisionOperationId,
1990
- ),
1991
- })
1992
- .then((operations) => {
1993
- programmaticRecoveryOutcome = "completed";
1994
- return operations;
1995
- })
1996
- .finally(() => {
1997
- recordTurnStartupPhase(observability, {
1998
- phase: "programmatic_operation_recovery",
1999
- provider: turnExecutionPolicy.providerId,
2000
- backend: activeSandboxBackend ?? groupBoxBackend,
2001
- outcome: programmaticRecoveryOutcome,
2002
- durationSeconds: (performance.now() - programmaticRecoveryStartedAt) / 1_000,
2003
- });
2004
- });
1972
+ // Join-on-use: lazy MCP preparation is awaited only when stored work must resume.
1973
+ const codemodeOperations = await CodemodeAttemptDispatcher.resumeApproved(
1974
+ db,
1975
+ {
1976
+ accountId: input.accountId,
1977
+ workspaceId: input.workspaceId,
1978
+ sessionId: input.sessionId,
1979
+ turnId: activeTurnId,
1980
+ attemptId: input.attemptId,
1981
+ executionGeneration: attempt.executionGeneration,
1982
+ },
1983
+ `sandbox:${input.attemptId}`,
1984
+ trigger.type === "user.approvalDecision"
1985
+ ? (trigger.payload as { approvalId: string }).approvalId
1986
+ : undefined,
1987
+ async () => {
1988
+ await eventing.toolPreparationReady;
1989
+ return eventing.codemodeDispatcher;
1990
+ },
1991
+ );
2005
1992
  codemodeContinuationNote = programmaticContinuationNote(codemodeOperations);
2006
1993
  programmaticApprovalAcknowledged =
2007
1994
  trigger.type === "user.approvalDecision" &&
@@ -103,6 +103,9 @@ export type SandboxRuntimeState = {
103
103
  }>;
104
104
  turnSandboxProvisioner: TurnSandboxProvisioner<ResumedTurnSandbox> | null;
105
105
  resumeManagedGroupBox: (() => Promise<ResumedTurnSandbox>) | null;
106
+ /** Physical resumeBoxForTurn promises still running for this attempt; the
107
+ * finalizer joins them so a cancelled establish completes its own cleanup. */
108
+ inFlightSandboxResumes: Set<Promise<unknown>>;
106
109
  prefetchedManagedBox: Promise<ResumedTurnSandbox> | null;
107
110
  prefetchedManagedBoxResult: ResumedTurnSandbox | null;
108
111
  setupBoxSession: unknown;
@@ -153,6 +156,9 @@ export type EventingState = {
153
156
  firstModelRequestPreparationRecorded: boolean;
154
157
  firstModelRequestCheckpointAt: number | null;
155
158
  initialModelWireDispatch: InitialModelWireDispatchClock;
159
+ /** Diagnostic only: the current stream's timing hook for a native transport's
160
+ * literal provider dispatch. Never joins, fences or fails the request. */
161
+ providerDispatchObserver: (() => void) | null;
156
162
  companyBrainContextContributions: readonly ModelContextContributionSummary[] | null;
157
163
  /** Skill ids in this turn's frozen, model-visible Skill index; telemetry only. */
158
164
  modelVisibleSkillIds: ReadonlySet<string> | null;
@@ -259,6 +265,7 @@ export function createTurnContext(input: {
259
265
  firstModelPreparationNestedSandboxPhases: [],
260
266
  turnSandboxProvisioner: null,
261
267
  resumeManagedGroupBox: null,
268
+ inFlightSandboxResumes: new Set(),
262
269
  prefetchedManagedBox: null,
263
270
  prefetchedManagedBoxResult: null,
264
271
  setupBoxSession: null,
@@ -301,6 +308,7 @@ export function createTurnContext(input: {
301
308
  firstModelRequestPreparationRecorded: false,
302
309
  firstModelRequestCheckpointAt: null,
303
310
  initialModelWireDispatch: new InitialModelWireDispatchClock(),
311
+ providerDispatchObserver: null,
304
312
  companyBrainContextContributions: null,
305
313
  modelVisibleSkillIds: null,
306
314
  },
@@ -41,7 +41,10 @@ import { hostedWebSearchForTurn } from "./tool-policy";
41
41
  * call this with the same accepted model and settings, so they agree.
42
42
  */
43
43
  export function turnWebSearchPlan(
44
- resolvedModel: { configured: { hostedWebSearch: boolean }; provider: { kind?: string } } | null,
44
+ resolvedModel: {
45
+ configured: { hostedWebSearch: boolean };
46
+ provider: { kind?: string };
47
+ } | null,
45
48
  settings: WebSearchSettings,
46
49
  ) {
47
50
  return webSearchToolPlan(settings, {
@@ -92,6 +95,48 @@ function providerFailure(error: WebSearchProviderError): string {
92
95
  return `${error.message}${error.retryable ? ". You may retry shortly." : "."}`;
93
96
  }
94
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
+
95
140
  /**
96
141
  * The `web_search` and `web_fetch` attempt tools for one turn. The caller
97
142
  * decides which names the turn gets ({@link webSearchToolPlan}); this only
@@ -102,7 +147,7 @@ export function webSearchToolDefinitions(input: {
102
147
  tools: readonly WebSearchProviderToolName[];
103
148
  scope: WebSearchCallScope;
104
149
  billing: WebSearchBilling;
105
- observability: Pick<Observability, "warn">;
150
+ observability: WebSearchObservability;
106
151
  fetch?: typeof fetch;
107
152
  }): AttemptToolDefinition[] {
108
153
  const config: WebSearchProviderConfig | null = webSearchProviderConfig(input.settings);
@@ -127,7 +172,10 @@ export function webSearchToolDefinitions(input: {
127
172
  const definitions: AttemptToolDefinition[] = [];
128
173
 
129
174
  if (input.tools.includes(WEB_SEARCH_TOOL_NAME)) {
130
- const provider = createWebSearchProvider({ ...adapterInput, endpoint: config.search });
175
+ const provider = createWebSearchProvider({
176
+ ...adapterInput,
177
+ endpoint: config.search,
178
+ });
131
179
  const pricing = webSearchCallPricing(config, "search");
132
180
  definitions.push({
133
181
  identity: { serverId: "opengeni", toolName: WEB_SEARCH_TOOL_NAME },
@@ -151,6 +199,15 @@ export function webSearchToolDefinitions(input: {
151
199
  request = parseWebSearchArguments(args);
152
200
  await input.billing.admit(input.scope, pricing.providerMicros);
153
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
+ }
154
211
  if (
155
212
  error instanceof WebToolArgumentError ||
156
213
  error instanceof WebSearchBillingRefusedError
@@ -158,8 +215,18 @@ export function webSearchToolDefinitions(input: {
158
215
  return textResult(error.message, true);
159
216
  throw error;
160
217
  }
218
+ const startedAt = performance.now();
161
219
  try {
162
- const response = await provider.search(request, { signal: context.signal });
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
+ );
163
230
  await settle(
164
231
  settledCost(
165
232
  context.operationId,
@@ -173,8 +240,29 @@ export function webSearchToolDefinitions(input: {
173
240
  } catch (error) {
174
241
  if (context.signal?.aborted) throw error;
175
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
+ });
176
257
  return textResult(`Web search failed: ${providerFailure(error)}`, true);
177
258
  }
259
+ recordWebSearchCall(
260
+ input.observability,
261
+ "search",
262
+ config.search.provider,
263
+ "error",
264
+ startedAt,
265
+ );
178
266
  throw error;
179
267
  }
180
268
  },
@@ -183,7 +271,10 @@ export function webSearchToolDefinitions(input: {
183
271
 
184
272
  if (input.tools.includes(WEB_FETCH_TOOL_NAME) && config.fetch) {
185
273
  const fetchEndpoint = config.fetch;
186
- const provider = createWebFetchProvider({ ...adapterInput, endpoint: fetchEndpoint });
274
+ const provider = createWebFetchProvider({
275
+ ...adapterInput,
276
+ endpoint: fetchEndpoint,
277
+ });
187
278
  const pricing = webSearchCallPricing(config, "fetch");
188
279
  const pages = new Map<string, WebPage>();
189
280
  definitions.push({
@@ -215,11 +306,28 @@ export function webSearchToolDefinitions(input: {
215
306
  try {
216
307
  await input.billing.admit(input.scope, pricing.providerMicros);
217
308
  } catch (error) {
218
- if (error instanceof WebSearchBillingRefusedError) return textResult(error.message, true);
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
+ }
219
319
  throw error;
220
320
  }
321
+ const startedAt = performance.now();
221
322
  try {
222
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
+ );
223
331
  await settle(
224
332
  settledCost(
225
333
  context.operationId,
@@ -241,8 +349,29 @@ export function webSearchToolDefinitions(input: {
241
349
  } catch (error) {
242
350
  if (context.signal?.aborted) throw error;
243
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
+ });
244
366
  return textResult(`Web fetch failed: ${providerFailure(error)}`, true);
245
367
  }
368
+ recordWebSearchCall(
369
+ input.observability,
370
+ "fetch",
371
+ fetchEndpoint.provider,
372
+ "error",
373
+ startedAt,
374
+ );
246
375
  throw error;
247
376
  }
248
377
  },
@@ -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,
@@ -24,29 +24,3 @@ export function programmaticContinuationNote(
24
24
  "Approval resumes the stored operation, not the earlier program's JavaScript stack. Read results with the current Codemode client's status/resume handle. Do not resubmit arguments for completed, running, waiting, or uncertain operations.",
25
25
  ].join("\n");
26
26
  }
27
-
28
- /**
29
- * Recover committed programmatic approval waits/results before the first model
30
- * request without turning lazy MCP preparation into a first-request barrier.
31
- *
32
- * Only eager servers may gate the first request. Resuming a stored operation
33
- * needs the prepared attempt tool environment, so this waits for full tool
34
- * preparation only when there is something to resume: an approval decision
35
- * trigger, or a cheap durable read showing unfinished operations for this
36
- * logical turn (which covers every recovery that has stored work). Every other
37
- * turn sends its first request without waiting on any lazy server.
38
- */
39
- export async function recoverProgrammaticOperationsBeforeFirstRequest(input: {
40
- approvalDecisionId: string | undefined;
41
- hasUnfinishedOperations: () => Promise<boolean>;
42
- toolPreparationReady: Promise<void> | null;
43
- resumeApproved: (
44
- decisionOperationId: string | undefined,
45
- ) => Promise<CodemodeOperation[]> | undefined;
46
- }): Promise<CodemodeOperation[]> {
47
- if (input.approvalDecisionId === undefined && !(await input.hasUnfinishedOperations())) {
48
- return [];
49
- }
50
- await input.toolPreparationReady;
51
- return (await input.resumeApproved(input.approvalDecisionId)) ?? [];
52
- }
@@ -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) {