@opengeni/runtime 0.6.0 → 0.7.0

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 (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2573 -897
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +99 -24
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -0,0 +1,70 @@
1
+ // Durable op-identity correlation (op-stream ruling B1): the DURABLE op id a
2
+ // sandbox transport op carries is `{sdk_tool_call_id}:{ordinal}` — minted ABOVE
3
+ // the transport, at the semantic layer. The tool call id lives in the model's
4
+ // function_call (persisted run history), so a re-dispatched turn re-executes the
5
+ // SAME function_call with the SAME call id, and the transport's idempotent
6
+ // OpStart turns an at-least-once re-execution into an ATTACH instead of a
7
+ // re-run. The ordinal is the deterministic position of the physical sub-op
8
+ // inside one tool invocation (a tool that performs read→write→rm mints :0, :1,
9
+ // :2), so a re-executed invocation re-mints identical ids.
10
+ //
11
+ // The correlation rides an AsyncLocalStorage bound around the SDK tool's
12
+ // `execute` (see `withExecOpCorrelation` in the runtime barrel): the SDK's tool
13
+ // machinery passes `details.toolCall` (with `callId`) into every function-tool
14
+ // invocation, and the shell capability's `configureTools` hook lets us wrap
15
+ // exec_command without touching the SDK. Per-async-chain storage keeps PARALLEL
16
+ // tool calls correctly separated.
17
+ //
18
+ // This module is deliberately dependency-free (node:async_hooks only) so the
19
+ // agent-loop-free sandbox leaf may import it: the leaf READS the context; only
20
+ // the runtime barrel (which owns the SDK imports) BINDS it.
21
+
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+
24
+ interface ToolCallCorrelation {
25
+ /** The sanitized sdk tool call id (a legal NATS subject-token fragment). */
26
+ callId: string;
27
+ /** The next sub-op ordinal within this tool invocation (mutable). */
28
+ ordinal: number;
29
+ }
30
+
31
+ const storage = new AsyncLocalStorage<ToolCallCorrelation>();
32
+
33
+ /**
34
+ * Sanitize a tool call id into a legal NATS subject TOKEN fragment. The op id
35
+ * is interpolated into the per-op frame subject, whose tokens must not contain
36
+ * whitespace/control characters or the subject-structure characters (`.`,
37
+ * `*`, `>`); the runner refuses illegal ids loudly. The mapping is INJECTIVE
38
+ * (each disallowed char becomes `_<hex>_`), so two distinct call ids can never
39
+ * collide into one op id — a collision would merge two different execs through
40
+ * the idempotent-OpStart dedup and return the wrong result.
41
+ */
42
+ export function sanitizeOpIdToken(raw: string): string {
43
+ return raw.replace(/[^A-Za-z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`);
44
+ }
45
+
46
+ /**
47
+ * Bind a tool-call correlation context around `fn` (the tool's execute). Every
48
+ * durable op id minted inside — however deep in the transport — is
49
+ * `{callId}:{ordinal}` with ordinals starting at 0 per invocation.
50
+ */
51
+ export function runWithToolCallCorrelation<T>(callId: string, fn: () => T): T {
52
+ return storage.run({ callId: sanitizeOpIdToken(callId), ordinal: 0 }, fn);
53
+ }
54
+
55
+ /**
56
+ * Mint the next durable op id for the current tool invocation, or null when no
57
+ * correlation context is bound (a non-tool caller, e.g. Channel-A structural
58
+ * exec). Callers fall back to a random unique id — safe (never collides, never
59
+ * wrongly dedups), merely not stable across a turn re-dispatch, which degrades
60
+ * that one op to today's at-least-once semantics.
61
+ */
62
+ export function nextDurableOpId(): string | null {
63
+ const context = storage.getStore();
64
+ if (!context) {
65
+ return null;
66
+ }
67
+ const ordinal = context.ordinal;
68
+ context.ordinal += 1;
69
+ return `${context.callId}:${ordinal}`;
70
+ }
@@ -66,16 +66,22 @@ export function assertProviderRegistryInvariants(): void {
66
66
  for (const backend of SandboxBackend.options) {
67
67
  const registration = PROVIDER_REGISTRY[backend];
68
68
  if (registration.backend !== backend) {
69
- throw new Error(`PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`);
69
+ throw new Error(
70
+ `PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`,
71
+ );
70
72
  }
71
73
  if (registration.descriptor.backend !== backend) {
72
- throw new Error(`PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`);
74
+ throw new Error(
75
+ `PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`,
76
+ );
73
77
  }
74
78
  if (backend === "none") {
75
79
  // "none" has no SDK client (build returns undefined); the descriptor
76
80
  // backendId "none" is self-consistent.
77
81
  if (registration.descriptor.backendId !== "none") {
78
- throw new Error(`"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`);
82
+ throw new Error(
83
+ `"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`,
84
+ );
79
85
  }
80
86
  continue;
81
87
  }
@@ -6,7 +6,14 @@ import { SandboxConfigError } from "../errors";
6
6
  import type { ProviderRegistration } from "./types";
7
7
 
8
8
  const MODAL_ORPHAN_SWEEP_LIMIT = 50;
9
- const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 30 * 60_000;
9
+ // A provider box is invisible to the lease until Modal create + manifest
10
+ // materialization returns and the creation callback records its instance id.
11
+ // Production baseline (2026-07-15, all 8 turn workers): 155/155 completed creates
12
+ // finished under 10s (154 under 2.5s). Two minutes is a 12x observed-max buffer,
13
+ // while avoiding the former 30-minute retention of boxes abandoned by a rolling
14
+ // worker restart. The live-instance guard below remains authoritative: once a box
15
+ // is recorded by any lease, age and missing/stale tags can never terminate it.
16
+ const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 2 * 60_000;
10
17
 
11
18
  export type ModalSandboxAttribution = {
12
19
  leaseId: string;
@@ -31,7 +38,9 @@ export type ModalOrphanSweepResult = {
31
38
  skipped: number;
32
39
  };
33
40
 
34
- export function modalSandboxAttributionEnvironment(input: ModalSandboxAttribution): Record<string, string> {
41
+ export function modalSandboxAttributionEnvironment(
42
+ input: ModalSandboxAttribution,
43
+ ): Record<string, string> {
35
44
  return {
36
45
  OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
37
46
  OPENGENI_SANDBOX_GROUP_ID: input.sandboxGroupId,
@@ -39,7 +48,9 @@ export function modalSandboxAttributionEnvironment(input: ModalSandboxAttributio
39
48
  };
40
49
  }
41
50
 
42
- export function modalSandboxAttributionTags(input: ModalSandboxAttribution): Record<string, string> {
51
+ export function modalSandboxAttributionTags(
52
+ input: ModalSandboxAttribution,
53
+ ): Record<string, string> {
43
54
  return {
44
55
  opengeni: "true",
45
56
  opengeni_lease_id: input.leaseId,
@@ -70,10 +81,11 @@ export const modalProvider: ProviderRegistration = {
70
81
  sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1000),
71
82
  exposedPorts,
72
83
  env: environment,
73
- // The Modal JS SDK's sandbox default command already sleeps until timeout
74
- // or explicit termination. Do not let the Agents extension stamp a separate
75
- // hardcoded sleep command; OPENGENI_MODAL_TIMEOUT_SECONDS owns lifetime.
76
- useSleepCmd: false,
84
+ // A registry image's own CMD is not a sandbox keepalive contract (for
85
+ // example, python:3.12-slim can exit immediately). Keep the provider's
86
+ // control process alive so exec/resume remains available; Modal's hard
87
+ // timeout and explicit OpenGeni teardown still own the box lifetime.
88
+ useSleepCmd: true,
77
89
  };
78
90
  // gap-fill (module 03 §4.1): these SDK options were previously unmapped.
79
91
  // ALWAYS pin idleTimeoutMs (sandbox-file-persistence): an UNSET idle timeout
@@ -115,14 +127,13 @@ type ModalClientLike = InstanceType<ModalModule["ModalClient"]>;
115
127
  // `fromRegistry(tag, secret)` image ONCE per process, then hand the provider `build`
116
128
  // a `ModalImageSelector.fromImage(...)`. `build` is synchronous and modal is imported
117
129
  // lazily (never loaded for non-modal backends), so resolution can't happen inside
118
- // `build`; the worker boot awaits `ensureModalRegistryImage` first and `build` reads
119
- // the settled result. Modal images are lazy, workspace-scoped definitions, so an image
120
- // built by this module's client is usable by the ModalSandboxClient's own client.
130
+ // `build`; the worker awaits `ensureModalRegistryImage` at boot for global refs and
131
+ // at turn time for pack-scoped refs, then `build` reads the settled result. Modal
132
+ // images are lazy, workspace-scoped definitions, so an image built by this module's
133
+ // client is usable by the ModalSandboxClient's own client.
121
134
 
122
135
  /** Loader seam so unit tests can inject a fake modal module. */
123
- export type ModalModuleLoader = () => Promise<
124
- Pick<ModalModule, "ModalClient" | "Secret">
125
- >;
136
+ export type ModalModuleLoader = () => Promise<Pick<ModalModule, "ModalClient">>;
126
137
 
127
138
  const defaultModalLoader: ModalModuleLoader = () => import("modal");
128
139
 
@@ -143,8 +154,9 @@ function registryImageCacheKey(settings: Settings): string {
143
154
  * Resolve + cache the private-registry Modal image. No-op unless BOTH
144
155
  * `modalImageRef` and `modalImageRegistrySecret` are set. Memoized per
145
156
  * (imageRef, secret, environment) so it runs once per worker process. Awaited at
146
- * worker boot BEFORE the first sandbox is created; `build` then reads the resolved
147
- * image and otherwise falls back to the public `fromTag` path.
157
+ * worker boot for the deployment-global image and at turn time for pack-scoped
158
+ * images BEFORE the first sandbox using that ref is created; `build` then reads the
159
+ * resolved image and otherwise falls back to the public `fromTag` path.
148
160
  */
149
161
  export async function ensureModalRegistryImage(
150
162
  settings: Settings,
@@ -162,7 +174,12 @@ export async function ensureModalRegistryImage(
162
174
  pending = (async () => {
163
175
  const modal = await loadModal();
164
176
  const client = new modal.ModalClient(modalClientOptions(settings));
165
- const secret = await modal.Secret.fromName(
177
+ // Resolve the Secret via the AUTHENTICATED client (client.secrets.fromName),
178
+ // NOT the static `modal.Secret.fromName`, which resolves against
179
+ // `getDefaultClient()` — i.e. the standard MODAL_TOKEN_ID/MODAL_TOKEN_SECRET env
180
+ // or ~/.modal.toml — and so would throw "Profile is missing token_id" in any host
181
+ // that supplies the token only through OpenGeni settings (OPENGENI_MODAL_TOKEN_ID).
182
+ const secret = await client.secrets.fromName(
166
183
  settings.modalImageRegistrySecret!,
167
184
  settings.modalEnvironment ? { environment: settings.modalEnvironment } : undefined,
168
185
  );
@@ -189,7 +206,7 @@ function cachedModalRegistryImage(settings: Settings): unknown | undefined {
189
206
  /**
190
207
  * Choose the image selector for a Modal sandbox client from settings. Returns:
191
208
  * - `fromImage(resolved)` when a private-registry secret is configured AND the
192
- * image has been resolved (ensureModalRegistryImage ran at worker boot);
209
+ * image has been resolved (ensureModalRegistryImage ran before create);
193
210
  * - `fromTag(modalImageRef)` for the public path (no secret, or cold cache — the
194
211
  * resume/attach paths never pull an image so the tag branch is harmless there);
195
212
  * - `undefined` when no image ref is set (Modal uses its default image).
@@ -201,7 +218,9 @@ export function resolveModalImageSelector(settings: Settings): ModalImageSelecto
201
218
  }
202
219
  const registryImage = cachedModalRegistryImage(settings);
203
220
  return registryImage
204
- ? ModalImageSelector.fromImage(registryImage as Parameters<typeof ModalImageSelector.fromImage>[0])
221
+ ? ModalImageSelector.fromImage(
222
+ registryImage as Parameters<typeof ModalImageSelector.fromImage>[0],
223
+ )
205
224
  : ModalImageSelector.fromTag(settings.modalImageRef);
206
225
  }
207
226
 
@@ -211,7 +230,9 @@ export function __resetModalRegistryImageCacheForTest(): void {
211
230
  inFlightRegistryImages.clear();
212
231
  }
213
232
 
214
- function modalClientOptions(settings: Settings): ConstructorParameters<ModalModule["ModalClient"]>[0] {
233
+ function modalClientOptions(
234
+ settings: Settings,
235
+ ): ConstructorParameters<ModalModule["ModalClient"]>[0] {
215
236
  return {
216
237
  ...(settings.modalTokenId ? { tokenId: settings.modalTokenId } : {}),
217
238
  ...(settings.modalTokenSecret ? { tokenSecret: settings.modalTokenSecret } : {}),
@@ -243,7 +264,10 @@ export async function tagModalSandbox(
243
264
  }
244
265
  }
245
266
 
246
- export async function terminateModalSandboxById(settings: Settings, sandboxId: string): Promise<boolean> {
267
+ export async function terminateModalSandboxById(
268
+ settings: Settings,
269
+ sandboxId: string,
270
+ ): Promise<boolean> {
247
271
  if (!sandboxId) {
248
272
  return true;
249
273
  }
@@ -286,14 +310,22 @@ function tagsFromInfo(info: ModalSandboxInfo): Record<string, string> {
286
310
  }
287
311
 
288
312
  function sandboxCreatedAtMs(info: ModalSandboxInfo): number | null {
289
- if (typeof info.createdAt !== "number" || !Number.isFinite(info.createdAt) || info.createdAt <= 0) {
313
+ if (
314
+ typeof info.createdAt !== "number" ||
315
+ !Number.isFinite(info.createdAt) ||
316
+ info.createdAt <= 0
317
+ ) {
290
318
  return null;
291
319
  }
292
320
  // Modal protobuf timestamps in this SDK are seconds as doubles.
293
- return info.createdAt < 10_000_000_000 ? Math.floor(info.createdAt * 1000) : Math.floor(info.createdAt);
321
+ return info.createdAt < 10_000_000_000
322
+ ? Math.floor(info.createdAt * 1000)
323
+ : Math.floor(info.createdAt);
294
324
  }
295
325
 
296
- function attributionKey(input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">): string {
326
+ function attributionKey(
327
+ input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">,
328
+ ): string {
297
329
  return `${input.workspaceId}:${input.sandboxGroupId}:${input.leaseId}`;
298
330
  }
299
331
 
@@ -311,6 +343,18 @@ export async function sweepModalOrphanSandboxes(
311
343
  const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
312
344
  const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
313
345
  const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
346
+ // LIVE-INSTANCE GUARD: a box that any live lease's envelope points at is NEVER
347
+ // an orphan, whatever its tags say. Tags are best-effort attribution (setTags
348
+ // is a separate call after create and can fail or lag); the lease envelope is
349
+ // the source of truth the turn path actually resumes by. Judging by tags alone
350
+ // terminated a LIVE box mid-turn at exactly creation+30min (staging session
351
+ // e644e8a8, 2026-07-06) — the box's unpushed work was unrecoverable because
352
+ // nothing outside the reaper drain persists /workspace.
353
+ const liveByInstanceId = new Map(
354
+ liveLeases
355
+ .filter((lease) => lease.instanceId)
356
+ .map((lease) => [lease.instanceId as string, lease]),
357
+ );
314
358
  const ownedClient = options.client ? null : await createModalClient(settings);
315
359
  const modal = (options.client ?? ownedClient)! as ModalCpListClient;
316
360
  try {
@@ -345,9 +389,40 @@ export async function sweepModalOrphanSandboxes(
345
389
  const leaseId = tags.opengeni_lease_id;
346
390
  const workspaceId = tags.opengeni_workspace_id;
347
391
  const sandboxGroupId = tags.opengeni_sandbox_group_id;
392
+ const liveByInstance = info.id ? liveByInstanceId.get(info.id) : undefined;
393
+ if (liveByInstance) {
394
+ // Live-instance guard (see above): a live lease resumes this exact box
395
+ // by id — hard-skip it, and HEAL its attribution tags when they are
396
+ // missing/stale so it stops looking sweep-eligible. Best-effort: a
397
+ // failed re-tag must never fail the sweep (the guard, not the tags,
398
+ // is what protects the box now).
399
+ if (
400
+ leaseId !== liveByInstance.leaseId ||
401
+ workspaceId !== liveByInstance.workspaceId ||
402
+ sandboxGroupId !== liveByInstance.sandboxGroupId
403
+ ) {
404
+ try {
405
+ const sandbox = await modal.sandboxes.fromId(info.id);
406
+ await sandbox.setTags(
407
+ modalSandboxAttributionTags({
408
+ leaseId: liveByInstance.leaseId,
409
+ workspaceId: liveByInstance.workspaceId,
410
+ sandboxGroupId: liveByInstance.sandboxGroupId,
411
+ }),
412
+ );
413
+ } catch {
414
+ // Tag healing is opportunistic; the instance guard already
415
+ // protects this box on every future sweep pass.
416
+ }
417
+ }
418
+ skipped += 1;
419
+ continue;
420
+ }
348
421
  let reason: ModalOrphanSweepTermination["reason"] | null = null;
349
422
  if (leaseId && workspaceId && sandboxGroupId) {
350
- const live = liveByAttribution.get(attributionKey({ leaseId, workspaceId, sandboxGroupId }));
423
+ const live = liveByAttribution.get(
424
+ attributionKey({ leaseId, workspaceId, sandboxGroupId }),
425
+ );
351
426
  if (!live || (live.instanceId && live.instanceId !== info.id)) {
352
427
  reason = "stale_attribution";
353
428
  }
@@ -20,10 +20,7 @@ import {
20
20
  type ControlRpc,
21
21
  type NatsRequestConnection,
22
22
  } from "../selfhosted/control-rpc";
23
- import {
24
- SelfhostedSandboxClient,
25
- type SelfhostedRelayConfig,
26
- } from "../selfhosted/session";
23
+ import { SelfhostedSandboxClient, type SelfhostedRelayConfig } from "../selfhosted/session";
27
24
  import type { ProviderRegistration } from "./types";
28
25
 
29
26
  /**