@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
@@ -1071,6 +1071,8 @@ export const SANDBOX_INVENTORY_PROJECTION_DOMAINS = [
1071
1071
  "retained_processes",
1072
1072
  "expired_drains",
1073
1073
  "opensandbox_kubernetes",
1074
+ "modal_provider",
1075
+ "interaction_idle",
1074
1076
  ] as const;
1075
1077
 
1076
1078
  export type SandboxInventoryProjectionDomain =
@@ -1228,6 +1230,49 @@ export function recordVerifiedSignupTrialDeploymentFlagGauge(
1228
1230
  });
1229
1231
  }
1230
1232
 
1233
+ /**
1234
+ * Provider-side Modal inventory reconciled against live leases by the orphan
1235
+ * sweep: `running` = every running box in the app, `unleased` = running boxes
1236
+ * no live lease protects (orphans, counted before termination), and
1237
+ * `lease_missing_instance` = live (warming/warm/draining) leases whose exact
1238
+ * provider instance is no longer running (zombies).
1239
+ */
1240
+ export function recordModalSandboxInventoryGauges(
1241
+ observability: Observability,
1242
+ inventory: { running: number; unleased: number; liveLeaseInstancesMissing: number },
1243
+ ): void {
1244
+ const states = {
1245
+ running: inventory.running,
1246
+ unleased: inventory.unleased,
1247
+ lease_missing_instance: inventory.liveLeaseInstancesMissing,
1248
+ };
1249
+ for (const [state, value] of Object.entries(states)) {
1250
+ observability.setGauge({
1251
+ name: "opengeni_modal_sandbox_inventory",
1252
+ help: "Running Modal sandboxes reconciled against live sandbox leases by the orphan sweep.",
1253
+ labels: { state },
1254
+ value: Math.max(0, value),
1255
+ });
1256
+ }
1257
+ }
1258
+
1259
+ /** Warm leases held only by Browser/Computer interaction holders, by time since
1260
+ * the newest interaction activity. Such boxes never drain on their own until the
1261
+ * interaction session ends or the provider deadline. */
1262
+ export function recordInteractionOnlyLeaseGauges(
1263
+ observability: Observability,
1264
+ counts: Record<string, number>,
1265
+ ): void {
1266
+ for (const [idleBucket, value] of Object.entries(counts)) {
1267
+ observability.setGauge({
1268
+ name: "opengeni_sandbox_leases_interaction_only",
1269
+ help: "Warm sandbox leases held only by Browser/Computer sessions, by time since last interaction activity.",
1270
+ labels: { idle_bucket: idleBucket },
1271
+ value: Math.max(0, value),
1272
+ });
1273
+ }
1274
+ }
1275
+
1231
1276
  export function recordSandboxOrphansTerminated(observability: Observability, count: number): void {
1232
1277
  if (count <= 0) {
1233
1278
  return;
@@ -1629,7 +1674,6 @@ export type TurnStartupPhase =
1629
1674
  | "post_tool_preparation"
1630
1675
  | "agent_construction"
1631
1676
  | "post_agent_preparation"
1632
- | "programmatic_operation_recovery"
1633
1677
  | "file_materialization"
1634
1678
  | "history_preparation"
1635
1679
  | "history_system_update_load"
@@ -2104,34 +2148,61 @@ function contentDeltaClass(type: SessionEventType): StreamDeltaClass | null {
2104
2148
  return null;
2105
2149
  }
2106
2150
 
2107
- // TTFT and inter-delta live on a human-perceptible scale (tens of ms to a few
2108
- // seconds), so they get their own SHORT buckets — the default duration buckets
2109
- // (which run to 3600s) would collapse every real streaming value into one bucket.
2110
- const STREAM_TTFT_BUCKETS = [0.02, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1, 1.5, 2, 3, 5, 10];
2151
+ // TTFT lives on a human-perceptible scale, but reasoning models legitimately
2152
+ // think for tens of seconds before their first streamed token. The finite
2153
+ // buckets therefore run well past 10s: a top bucket at 10s made every slower
2154
+ // first token land in +Inf, so p50/p99 saturated at exactly "10" and hid how
2155
+ // slow the tail really was.
2156
+ const STREAM_TTFT_BUCKETS = [
2157
+ 0.02, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1, 1.5, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300,
2158
+ ];
2111
2159
  const STREAM_INTER_DELTA_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.35, 0.5, 1, 2, 5];
2160
+ // OpenGeni's own per-request work before the provider sees bytes: admission,
2161
+ // durable history/audit checkpoints, request build. Normally milliseconds to a
2162
+ // second; tens of seconds means our database or consumer is the bottleneck.
2163
+ const MODEL_REQUEST_PRE_DISPATCH_BUCKETS = [
2164
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60, 120,
2165
+ ];
2166
+
2167
+ /** First streamed content of a provider response. `any` is the first reasoning
2168
+ * or answer delta; `text` is the first answer-text delta. They overlap — never
2169
+ * sum across `content`. */
2170
+ export type ProviderFirstContent = "any" | "text";
2112
2171
 
2113
2172
  /**
2114
- * Per-turn stream-timing tracker fed every normalized runtime event in push order.
2115
- * It emits two model-responsiveness SLIs from the worker's seat on the stream:
2173
+ * Per-stream timing tracker fed every normalized runtime event in push order,
2174
+ * plus two producer-side hooks. It separates OUR latency from the PROVIDER's:
2116
2175
  *
2117
- * - `opengeni_stream_ttft_seconds{provider}` — time from a model (re)start to its
2118
- * first streamed content delta. The anchor starts at construction (≈ runStream
2119
- * start, so the first observation is "how long until text appears") and re-arms
2120
- * on every non-content event (a tool call, a completed message, a usage frame),
2121
- * so a post-tool response measures the model's restart latency, NOT our own
2122
- * tool-execution time.
2176
+ * - `opengeni_stream_ttft_seconds{provider}` — user-perceived (re)start latency:
2177
+ * time from a stream start / structural boundary (tool call, tool result,
2178
+ * completed message, usage frame) to the first streamed content delta. It
2179
+ * mixes our between-call work with provider time; keep it for the absolute
2180
+ * dashboard view, not for provider alerting.
2181
+ * - `opengeni_model_request_pre_dispatch_seconds{provider}` — OUR per-request
2182
+ * latency: SDK model entry (the first admission check) to the literal
2183
+ * provider dispatch. Covers admission, the durable history checkpoint,
2184
+ * credit revalidation, the durable request audit and request build.
2185
+ * - `opengeni_model_provider_ttft_seconds{provider,content}` — the PROVIDER's
2186
+ * latency: literal dispatch to the first streamed `any` (reasoning or text)
2187
+ * delta and to the first answer `text` delta. Includes network, provider
2188
+ * queueing, prompt prefill and reasoning before the first streamed token.
2123
2189
  * - `opengeni_stream_inter_delta_gap_seconds{provider,class}` — gap between
2124
- * consecutive content deltas of the SAME class. The run resets on any
2125
- * non-content event so a gap never spans a tool call or a model boundary — it
2126
- * measures only the choppiness of a live token stream.
2190
+ * consecutive content deltas of the SAME class, reset at every structural
2191
+ * event so a gap never spans a tool call or a model boundary.
2127
2192
  *
2128
- * Purely observational and clock-injectable; it never touches the events it sees.
2193
+ * Dispatch timing deliberately survives structural events: the consumer may
2194
+ * still be persisting the previous response's tool results when the producer
2195
+ * dispatches the next request, but the next request's first delta always
2196
+ * follows its own dispatch. Purely observational and clock-injectable.
2129
2197
  */
2130
2198
  export class StreamTimingMetrics {
2131
2199
  private readonly now: () => number;
2132
2200
  private ttftAnchor: number;
2133
2201
  private ttftArmed = true;
2134
2202
  private readonly lastDeltaAt = new Map<StreamDeltaClass, number>();
2203
+ private modelEntryAt: number | null = null;
2204
+ private dispatchedAt: number | null = null;
2205
+ private readonly providerFirstRecorded = new Set<ProviderFirstContent>();
2135
2206
 
2136
2207
  constructor(
2137
2208
  private readonly observability: Observability,
@@ -2141,6 +2212,31 @@ export class StreamTimingMetrics {
2141
2212
  this.ttftAnchor = this.now();
2142
2213
  }
2143
2214
 
2215
+ /** Producer side: the SDK entered a model request (first admission check).
2216
+ * Admission can be re-entered for the same request; keep the earliest. */
2217
+ onModelRequestEntry(): void {
2218
+ if (this.modelEntryAt === null) this.modelEntryAt = this.now();
2219
+ }
2220
+
2221
+ /** Producer side: request bytes are about to leave this process. */
2222
+ onProviderDispatch(): void {
2223
+ const at = this.now();
2224
+ if (this.modelEntryAt !== null) {
2225
+ this.observability.observeHistogram({
2226
+ name: "opengeni_model_request_pre_dispatch_seconds",
2227
+ help: "Seconds of OpenGeni work from SDK model-request entry to literal provider dispatch.",
2228
+ buckets: MODEL_REQUEST_PRE_DISPATCH_BUCKETS,
2229
+ labels: { provider: this.options.provider },
2230
+ value: Math.max(0, (at - this.modelEntryAt) / 1000),
2231
+ });
2232
+ this.modelEntryAt = null;
2233
+ }
2234
+ // A transport retry re-dispatches the same request; the first delta then
2235
+ // belongs to the latest dispatch.
2236
+ this.dispatchedAt = at;
2237
+ this.providerFirstRecorded.clear();
2238
+ }
2239
+
2144
2240
  onEvent(type: SessionEventType): void {
2145
2241
  const deltaClass = contentDeltaClass(type);
2146
2242
  if (deltaClass === null) {
@@ -2156,13 +2252,17 @@ export class StreamTimingMetrics {
2156
2252
  if (this.ttftArmed) {
2157
2253
  this.observability.observeHistogram({
2158
2254
  name: "opengeni_stream_ttft_seconds",
2159
- help: "Seconds from a model (re)start to its first streamed content delta.",
2255
+ help: "Seconds from a stream start or structural boundary to the next streamed content delta (user-perceived; includes OpenGeni between-call work).",
2160
2256
  buckets: STREAM_TTFT_BUCKETS,
2161
2257
  labels: { provider: this.options.provider },
2162
2258
  value: Math.max(0, (at - this.ttftAnchor) / 1000),
2163
2259
  });
2164
2260
  this.ttftArmed = false;
2165
2261
  }
2262
+ if (this.dispatchedAt !== null) {
2263
+ this.observeProviderFirst("any", at);
2264
+ if (deltaClass === "message") this.observeProviderFirst("text", at);
2265
+ }
2166
2266
  const last = this.lastDeltaAt.get(deltaClass);
2167
2267
  if (last !== undefined) {
2168
2268
  this.observability.observeHistogram({
@@ -2175,6 +2275,18 @@ export class StreamTimingMetrics {
2175
2275
  }
2176
2276
  this.lastDeltaAt.set(deltaClass, at);
2177
2277
  }
2278
+
2279
+ private observeProviderFirst(content: ProviderFirstContent, at: number): void {
2280
+ if (this.dispatchedAt === null || this.providerFirstRecorded.has(content)) return;
2281
+ this.providerFirstRecorded.add(content);
2282
+ this.observability.observeHistogram({
2283
+ name: "opengeni_model_provider_ttft_seconds",
2284
+ help: "Seconds from literal provider dispatch to the first streamed content delta (any = reasoning or text, text = answer text).",
2285
+ buckets: STREAM_TTFT_BUCKETS,
2286
+ labels: { provider: this.options.provider, content },
2287
+ value: Math.max(0, (at - this.dispatchedAt) / 1000),
2288
+ });
2289
+ }
2178
2290
  }
2179
2291
 
2180
2292
  // Batch shapes: sizes are small integers; durations are the append+publish round
@@ -166,6 +166,9 @@ export type SandboxResumeServices = {
166
166
  * replacement. Production uses {@link freshSandboxReadinessReplacementDelayMs}.
167
167
  * It may be async so a test can observe the rolled-back lease in between. */
168
168
  freshSandboxReadinessReplacementDelayMs?: () => number | Promise<number>;
169
+ /** Test seam: runs immediately after the elected spawner published its box
170
+ * warm, before the final cancellation check. */
171
+ onSpawnedSandboxPublished?: () => void | Promise<void>;
169
172
  /**
170
173
  * The turn attempt's fresh-box readiness replacement budget. The lazy
171
174
  * provisioner may call resumeBoxForTurn again after a typed lease
@@ -438,10 +441,6 @@ export class SandboxLeaseInstanceLostError extends SandboxLeaseSupersededError {
438
441
  // user-facing and separate from the lease TTL heartbeat/reaper horizon.
439
442
  const WARMING_POLL_INTERVAL_MS = 250;
440
443
 
441
- async function sleep(ms: number): Promise<void> {
442
- await new Promise<void>((resolve) => setTimeout(resolve, ms));
443
- }
444
-
445
444
  /**
446
445
  * A remote provider may return a sandbox handle before its command router
447
446
  * accepts the first exec. The upstream session's yieldTimeMs starts only after
@@ -1576,6 +1575,12 @@ async function resumeBoxForTurnOnce(
1576
1575
  if (acquired.role === "spawner") {
1577
1576
  const expectedEpoch = acquired.lease.leaseEpoch;
1578
1577
  let createdEstablished: EstablishedSandboxSession | null = null;
1578
+ // Set once commitWarmingToWarm publishes this box. From then on the box is
1579
+ // the group's shared, attachable workspace: a later cancellation (Pause,
1580
+ // Steer, worker shutdown) only drops this attempt's holder. Terminating it
1581
+ // here would hand the next attempt a warm lease naming a dead box, whose
1582
+ // exact-id resume then records a lost workspace that never lost anything.
1583
+ let published = false;
1579
1584
  let providerCreateOperationId: string | undefined;
1580
1585
  let providerCreateBindingKey: string | undefined;
1581
1586
  let rematerialization: {
@@ -2021,13 +2026,21 @@ async function resumeBoxForTurnOnce(
2021
2026
  await release();
2022
2027
  throw new SandboxLeaseSupersededError(ids.sandboxGroupId, expectedEpoch);
2023
2028
  }
2029
+ published = true;
2024
2030
  holderLeaseHeartbeat = {
2025
2031
  expectedEpoch: committed.lease.leaseEpoch,
2026
2032
  leaseTtlMs,
2027
2033
  };
2034
+ await services.onSpawnedSandboxPublished?.();
2028
2035
  throwIfReleasedOrCancelled();
2029
2036
  return { established, leaseEpoch: committed.lease.leaseEpoch, release };
2030
2037
  } catch (error) {
2038
+ if (published) {
2039
+ // The published warm box stays for the replacement attempt to resume
2040
+ // by exact provider id; ordinary idle drain owns its capture/teardown.
2041
+ await release();
2042
+ throw error;
2043
+ }
2031
2044
  if (error instanceof SandboxLeaseSupersededError) {
2032
2045
  await terminateEstablishedSandbox(createdEstablished);
2033
2046
  await release();
@@ -2203,7 +2216,13 @@ async function waitForWarm(
2203
2216
  const deadline = Date.now() + settings.sandboxWarmingTimeoutMs;
2204
2217
  let instanceId: string | null = null;
2205
2218
  while (Date.now() < deadline) {
2206
- await sleep(WARMING_POLL_INTERVAL_MS);
2219
+ // A cancelled waiter owns no box and must not keep polling for up to the
2220
+ // warming budget: its activity finalizer joins this exact promise.
2221
+ if (!(await sleepUnlessCancelled(WARMING_POLL_INTERVAL_MS, services.cancellationSignal))) {
2222
+ throw services.cancellationSignal?.reason instanceof Error
2223
+ ? services.cancellationSignal.reason
2224
+ : new Error("Sandbox warming wait was cancelled with its owning turn attempt");
2225
+ }
2207
2226
  const lease = await readLease(db, ids.workspaceId, ids.sandboxGroupId);
2208
2227
  if (!lease) {
2209
2228
  // Lease vanished (cold-reaped). Re-dispatch from scratch.
@@ -40,6 +40,7 @@ import {
40
40
  readActiveSandbox,
41
41
  resolvePersonalMachineConnectionForAttempt,
42
42
  SandboxRetainedProcessPromotionFencedError,
43
+ SandboxRetainedProcessTerminalError,
43
44
  SandboxWorkspaceMutationOutputRejectedError,
44
45
  type Database,
45
46
  type EnrollmentRecord,
@@ -700,6 +701,17 @@ function afterRetainedProcessMutation(
700
701
  outcome,
701
702
  })
702
703
  ) {
704
+ if (error.retainedProcessTerminal) {
705
+ // Another authority (the reaper's exact-proof reconciliation) settled
706
+ // this retained process terminal between admission and settlement.
707
+ // The admission is physically settled and its output stays rejected;
708
+ // surface the durable terminal truth so routing reports completion
709
+ // instead of failing the turn. Nothing is replayed.
710
+ throw new SandboxRetainedProcessTerminalError(
711
+ error.retainedProcessTerminal.state,
712
+ error.retainedProcessTerminal.exitCode,
713
+ );
714
+ }
703
715
  throw new RoutingMutationOutputRejectedError(op, error.code, { cause: error });
704
716
  }
705
717
  throw error;