@opengeni/worker-bundle 2.0.3 → 2.1.0-canary.36239117573001

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 (51) hide show
  1. package/dist/activities/agent-turn/agent-build.d.ts +2 -0
  2. package/dist/activities/agent-turn/code-search.d.ts +60 -0
  3. package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
  4. package/dist/activities/agent-turn/errors.d.ts +12 -1
  5. package/dist/activities/agent-turn/governance-model.d.ts +2 -0
  6. package/dist/activities/agent-turn/session-title.d.ts +32 -2
  7. package/dist/activities/agent-turn/tool-environment.d.ts +4 -0
  8. package/dist/activities/context-compaction.d.ts +10 -2
  9. package/dist/activities/knowledge-indexing.d.ts +19 -0
  10. package/dist/{activities-control-IVTL723D.js → activities-control-I72CRMAE.js} +81 -14
  11. package/dist/activities-control-I72CRMAE.js.map +1 -0
  12. package/dist/{activities-turn-EZEDZTEZ.js → activities-turn-CSDKK4N3.js} +425 -46
  13. package/dist/activities-turn-CSDKK4N3.js.map +1 -0
  14. package/dist/artifact-outbox-entry.js +3 -0
  15. package/dist/artifact-outbox-entry.js.map +1 -1
  16. package/dist/{chunk-JMO5ZUW3.js → chunk-7M6G2SCF.js} +159 -11
  17. package/dist/chunk-7M6G2SCF.js.map +1 -0
  18. package/dist/{chunk-I7HKKMUJ.js → chunk-DSPP6CZL.js} +48 -3
  19. package/dist/chunk-DSPP6CZL.js.map +1 -0
  20. package/dist/index.js +4 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/observability-metrics.d.ts +11 -1
  23. package/dist/sandbox-resume.d.ts +46 -0
  24. package/dist/workflow-bundle.js +77 -2
  25. package/dist/workflows/session.d.ts +30 -0
  26. package/package.json +20 -19
  27. package/src/activities/agent-turn/agent-build.ts +4 -0
  28. package/src/activities/agent-turn/code-search.ts +275 -0
  29. package/src/activities/agent-turn/codex-capacity.ts +37 -2
  30. package/src/activities/agent-turn/compaction-prep.ts +52 -16
  31. package/src/activities/agent-turn/errors.ts +59 -1
  32. package/src/activities/agent-turn/governance-model.ts +17 -1
  33. package/src/activities/agent-turn/run.ts +4 -0
  34. package/src/activities/agent-turn/sandbox-establish.ts +12 -1
  35. package/src/activities/agent-turn/session-title.ts +73 -3
  36. package/src/activities/agent-turn/stream-attempt.ts +14 -13
  37. package/src/activities/agent-turn/tool-environment.ts +65 -0
  38. package/src/activities/context-compaction.ts +49 -14
  39. package/src/activities/knowledge-indexing.ts +107 -9
  40. package/src/activities/scheduled-tasks.ts +18 -2
  41. package/src/activity-services.ts +16 -4
  42. package/src/editable-artifact-hint-broker.ts +3 -0
  43. package/src/index.ts +2 -2
  44. package/src/observability-metrics.ts +70 -1
  45. package/src/personal-github-git-credentials.ts +2 -0
  46. package/src/sandbox-resume.ts +205 -5
  47. package/src/workflows/session.ts +102 -6
  48. package/dist/activities-control-IVTL723D.js.map +0 -1
  49. package/dist/activities-turn-EZEDZTEZ.js.map +0 -1
  50. package/dist/chunk-I7HKKMUJ.js.map +0 -1
  51. package/dist/chunk-JMO5ZUW3.js.map +0 -1
@@ -20,6 +20,95 @@ import {
20
20
  import type { DocumentServices } from "@opengeni/documents";
21
21
  import type { ControlActivityServices } from "./types";
22
22
 
23
+ /** The configured monthly indexed-chunk limit, not a provider failure. */
24
+ export class KnowledgeIndexUsageLimitError extends Error {
25
+ constructor() {
26
+ super("monthly document indexing limit reached");
27
+ this.name = "KnowledgeIndexUsageLimitError";
28
+ }
29
+ }
30
+
31
+ export type KnowledgeIndexFailureStage = "embedding" | "processing";
32
+
33
+ /**
34
+ * Content-free classification for a deferred Knowledge index batch. Only
35
+ * protocol constants, an HTTP status, and a PostgreSQL SQLSTATE are retained;
36
+ * provider messages, bodies, SQL, and identifiers never leave the process.
37
+ * Outside the embedding call, only a PostgreSQL error in the cause chain is
38
+ * attributed to the database; any other failure stays a worker failure.
39
+ */
40
+ export function knowledgeIndexFailureDiagnostic(
41
+ stage: KnowledgeIndexFailureStage,
42
+ error: unknown,
43
+ ): {
44
+ errorClass: "KnowledgeIndexOperationError";
45
+ errorCode:
46
+ | "knowledge_index_usage_limit_reached"
47
+ | "knowledge_index_embedding_failed"
48
+ | "knowledge_index_persistence_failed"
49
+ | "knowledge_index_failed";
50
+ origin: "worker" | "db";
51
+ status?: number;
52
+ sqlState?: string;
53
+ } {
54
+ if (error instanceof KnowledgeIndexUsageLimitError) {
55
+ return {
56
+ errorClass: "KnowledgeIndexOperationError",
57
+ errorCode: "knowledge_index_usage_limit_reached",
58
+ origin: "worker",
59
+ };
60
+ }
61
+ if (stage === "embedding") {
62
+ const status = ownValue(error, "status");
63
+ return {
64
+ errorClass: "KnowledgeIndexOperationError",
65
+ errorCode: "knowledge_index_embedding_failed",
66
+ origin: "worker",
67
+ ...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599
68
+ ? { status }
69
+ : {}),
70
+ };
71
+ }
72
+ const postgres = postgresErrorState(error);
73
+ if (!postgres) {
74
+ return {
75
+ errorClass: "KnowledgeIndexOperationError",
76
+ errorCode: "knowledge_index_failed",
77
+ origin: "worker",
78
+ };
79
+ }
80
+ return {
81
+ errorClass: "KnowledgeIndexOperationError",
82
+ errorCode: "knowledge_index_persistence_failed",
83
+ origin: "db",
84
+ ...(postgres.sqlState ? { sqlState: postgres.sqlState } : {}),
85
+ };
86
+ }
87
+
88
+ function ownValue(value: unknown, key: string): unknown {
89
+ try {
90
+ if (!value || typeof value !== "object") return undefined;
91
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
92
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /** The nearest PostgreSQL error in a short cause chain, with its SQLSTATE
99
+ * when that is a well-formed five-character code. */
100
+ function postgresErrorState(error: unknown): { sqlState?: string } | undefined {
101
+ let current = error;
102
+ for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
103
+ if (ownValue(current, "name") === "PostgresError") {
104
+ const code = ownValue(current, "code");
105
+ return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code) ? { sqlState: code } : {};
106
+ }
107
+ current = ownValue(current, "cause");
108
+ }
109
+ return undefined;
110
+ }
111
+
23
112
  export function createKnowledgeIndexingActivities(
24
113
  services: () => Promise<ControlActivityServices>,
25
114
  resolveDocumentServices?: () => Promise<DocumentServices>,
@@ -52,6 +141,7 @@ export function createKnowledgeIndexingActivities(
52
141
  limit: 2,
53
142
  });
54
143
  for (const claim of claims) {
144
+ let stage: KnowledgeIndexFailureStage = "processing";
55
145
  try {
56
146
  const source = await readKnowledgeIndexSource(db, claim);
57
147
  if (!source) {
@@ -126,8 +216,7 @@ export function createKnowledgeIndexingActivities(
126
216
  eventType: "document.indexed",
127
217
  since: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
128
218
  });
129
- if (used + chunks.length > limit)
130
- throw new Error("monthly document indexing limit reached");
219
+ if (used + chunks.length > limit) throw new KnowledgeIndexUsageLimitError();
131
220
  }
132
221
  }
133
222
  const inputs = chunks.map((chunk) => chunk.embeddingInput);
@@ -135,9 +224,11 @@ export function createKnowledgeIndexingActivities(
135
224
  (sum, input) => sum + Buffer.byteLength(input, "utf8"),
136
225
  0,
137
226
  );
227
+ stage = "embedding";
138
228
  const vectors = await embedder.embedMany(inputs);
139
229
  if (vectors.length !== chunks.length)
140
230
  throw new Error("Incomplete Knowledge embeddings");
231
+ stage = "processing";
141
232
  // A reviewer may have rejected this revision during the provider
142
233
  // call. The DB guard holds its publication row through settlement.
143
234
  if (paid) {
@@ -239,16 +330,23 @@ export function createKnowledgeIndexingActivities(
239
330
  else result.unavailable++;
240
331
  }
241
332
  });
242
- } catch {
333
+ } catch (error) {
243
334
  // Provider failures retain the last completed projection. The durable
244
335
  // queue owns retry/backoff; do not retry an entire activity implicitly.
245
- await deferKnowledgeIndexJob(db, claim).catch(() => undefined);
246
- result.deferred++;
247
- observability.warn("Knowledge indexing batch deferred", {
248
- accountId: claim.accountId,
249
- entryId: claim.entryId,
250
- revisionId: claim.revisionId,
336
+ // The stored reason stays the SQL lifecycle's fixed code; the log
337
+ // carries the content-free class/code of the actual cause.
338
+ observability.warn(
339
+ "Knowledge indexing batch deferred",
340
+ knowledgeIndexFailureDiagnostic(stage, error),
341
+ );
342
+ await deferKnowledgeIndexJob(db, claim).catch((deferError: unknown) => {
343
+ const deferDiagnostic = knowledgeIndexFailureDiagnostic("processing", deferError);
344
+ observability.warn("Knowledge indexing batch deferral failed", {
345
+ ...deferDiagnostic,
346
+ errorCode: "knowledge_index_defer_failed",
347
+ });
251
348
  });
349
+ result.deferred++;
252
350
  }
253
351
  }
254
352
  return result;
@@ -20,6 +20,7 @@ import {
20
20
  openGeniSlackBotMetadata,
21
21
  requireOpenGeniSlackBotConnection,
22
22
  resolveWorkspaceCatalogSettings,
23
+ resolveScheduledTaskDefaultModel,
23
24
  resolveSessionToolPolicy,
24
25
  workspaceCustomModelReference,
25
26
  lockActiveCustomModelForAdmission,
@@ -438,8 +439,23 @@ export function createScheduledTaskActivities(services: () => Promise<ControlAct
438
439
  )
439
440
  : null;
440
441
  const settings = await settingsForTask(task, targetSessionExecutionBase?.model);
441
- const model = task.agentConfig.model ?? settings.openaiModel;
442
- const reasoningEffort = task.agentConfig.reasoningEffort ?? settings.openaiReasoningEffort;
442
+ // A task that names no model resolves the new-chat default at each fresh
443
+ // occurrence (saved workspace default, then a usable connected
444
+ // subscription under the task's frozen SuperGrok authority, then the
445
+ // credits default while the organization holds a credit balance, then the
446
+ // deployment default). A frozen SuperGrok pool that is gone only means
447
+ // SuperGrok is not ready. The accepted execution freezes the result, so
448
+ // retries and recovery never resolve again. Existing-session runs keep
449
+ // that session's model.
450
+ const resolvedDefault =
451
+ task.agentConfig.model || targetSessionExecutionBase
452
+ ? null
453
+ : await resolveScheduledTaskDefaultModel(db, settings, task);
454
+ const model = task.agentConfig.model ?? resolvedDefault?.model ?? settings.openaiModel;
455
+ const reasoningEffort =
456
+ task.agentConfig.reasoningEffort ??
457
+ resolvedDefault?.reasoningEffort ??
458
+ settings.openaiReasoningEffort;
443
459
  let sandboxBackend = task.agentConfig.sandboxBackend ?? settings.sandboxBackend;
444
460
  let sandboxOs: "linux" | "macos" | "windows" = "linux";
445
461
  const taskTools = withFirstPartyTools(settings, task.agentConfig.tools);
@@ -1,11 +1,20 @@
1
- import { dbSearchPath, getSettings, resolveNatsControlPlaneAuth } from "@opengeni/config";
2
- import { configureChildLifecycleNotices, createDb } from "@opengeni/db";
1
+ import {
2
+ codeSearchDeploymentPolicy,
3
+ dbSearchPath,
4
+ getSettings,
5
+ resolveNatsControlPlaneAuth,
6
+ } from "@opengeni/config";
7
+ import {
8
+ configureChildLifecycleNotices,
9
+ configureCodeSearchDeploymentPolicy,
10
+ createDb,
11
+ } from "@opengeni/db";
3
12
  import { createNatsEventBus } from "@opengeni/events";
4
13
  import { createObservability } from "@opengeni/observability";
5
14
  import { resolveCatalogSettings } from "@opengeni/core";
6
15
  import { createObjectStorage } from "@opengeni/storage";
7
16
  import type { ActivityDependencies, SharedActivityServices } from "./activities/types";
8
- import { observabilityEventLogger } from "./observability-metrics";
17
+ import { observabilityEventBusOptions } from "./observability-metrics";
9
18
  import { buildPersonalGitHubGitCredentials } from "./personal-github-git-credentials";
10
19
  import { createStandaloneConnectionCredentialsPort } from "./pr-review-credentials";
11
20
 
@@ -40,6 +49,9 @@ export function createSharedActivityServices(
40
49
  // Child lifecycle notice producers are process-global in @opengeni/db;
41
50
  // install the boot-validated rollout flag once for this worker.
42
51
  configureChildLifecycleNotices({ enabled: settings.childLifecycleNoticesEnabled });
52
+ // Child, scheduled and automation sessions created here freeze their
53
+ // code_search decision from the same deployment policy as the API.
54
+ configureCodeSearchDeploymentPolicy(codeSearchDeploymentPolicy(settings));
43
55
  return {
44
56
  settings: resolvedSettings,
45
57
  catalogSourceSettings: settings,
@@ -51,7 +63,7 @@ export function createSharedActivityServices(
51
63
  controlPlaneAuth
52
64
  ? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password }
53
65
  : undefined,
54
- { logger: observabilityEventLogger(observability) },
66
+ observabilityEventBusOptions(observability),
55
67
  )),
56
68
  objectStorage: dependencies.objectStorage ?? createObjectStorage(settings),
57
69
  observability,
@@ -161,6 +161,9 @@ export async function connectEditableArtifactHintBroker(
161
161
  name,
162
162
  reconnect: true,
163
163
  maxReconnectAttempts: -1,
164
+ // Same policy as the event bus: a broker or auth-callout restart must not
165
+ // permanently close this connection after two identical auth errors.
166
+ ignoreAuthErrorAbort: true,
164
167
  reconnectTimeWait: 2_000,
165
168
  reconnectJitter: 1_000,
166
169
  reconnectJitterTLS: 1_000,
package/src/index.ts CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  initializeContextCompactionMetrics,
62
62
  initializeWorkerOutcomeMetrics,
63
63
  normalizeTurnTaskQueueStats,
64
- observabilityEventLogger,
64
+ observabilityEventBusOptions,
65
65
  startContextCompactionPendingMonitor,
66
66
  startSessionRecoveryMonitor,
67
67
  startTurnCapacityMonitor,
@@ -1173,7 +1173,7 @@ export async function startWorker() {
1173
1173
  controlPlaneAuth
1174
1174
  ? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password }
1175
1175
  : undefined,
1176
- { logger: observabilityEventLogger(observability) },
1176
+ observabilityEventBusOptions(observability),
1177
1177
  ),
1178
1178
  { ...retryOptions, onRetry },
1179
1179
  );
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import { errorCodeToJSON } from "@opengeni/agent-proto";
3
3
  import { SandboxBackend, type SessionEventType } from "@opengeni/contracts";
4
4
  import type { SessionEventAppendPhaseObservation } from "@opengeni/db";
5
- import type { EventLogger } from "@opengeni/events";
5
+ import {
6
+ natsSubscriptionTerminationCounter,
7
+ type EventBusOptions,
8
+ type EventLogger,
9
+ } from "@opengeni/events";
6
10
  import type { Attributes, AttributeValue, Observability } from "@opengeni/observability";
7
11
  import type { CompanyBrainContributionReceipt } from "./model-context-contributions";
8
12
  import {
@@ -80,6 +84,16 @@ const CONTEXT_COMPACTIONS_METRIC = {
80
84
  help: "Total completed context compactions, by trigger.",
81
85
  } as const;
82
86
 
87
+ /** Logger plus the closed-label subscription-termination counter for NATS connections. */
88
+ export function observabilityEventBusOptions(
89
+ observability: Observability,
90
+ ): Pick<EventBusOptions, "logger" | "onSubscriptionTerminated"> {
91
+ return {
92
+ logger: observabilityEventLogger(observability),
93
+ onSubscriptionTerminated: natsSubscriptionTerminationCounter(observability),
94
+ };
95
+ }
96
+
83
97
  export function observabilityEventLogger(observability: Observability): EventLogger {
84
98
  return {
85
99
  debug: (message, attributes) => observability.debug(message, eventAttributes(attributes)),
@@ -156,6 +170,13 @@ export function runtimeMetricsHooksForObservability(
156
170
  labels: { backend, stage },
157
171
  });
158
172
  },
173
+ onSandboxReadinessReplacement: ({ backend, outcome }) => {
174
+ observability.incrementCounter({
175
+ name: "opengeni_sandbox_readiness_replacements_total",
176
+ help: "Fresh sandbox command-readiness replacement decisions by backend and outcome.",
177
+ labels: { backend, outcome },
178
+ });
179
+ },
159
180
  onSandboxProviderApiThrottle: ({ backend, operation }) => {
160
181
  observability.incrementCounter({
161
182
  name: "opengeni_sandbox_provider_api_throttles_total",
@@ -2264,3 +2285,51 @@ export function modelCallAccountContext(input: {
2264
2285
  accountChangedFromPrevCall,
2265
2286
  };
2266
2287
  }
2288
+
2289
+ export type CodeSearchCallOutcome =
2290
+ | "completed"
2291
+ | "jev_unavailable"
2292
+ | "jev_rejected"
2293
+ | "workspace_unavailable"
2294
+ | "invalid_arguments"
2295
+ | "breaker_open"
2296
+ | "cancelled"
2297
+ | "failed";
2298
+
2299
+ /** One `code_search` tool call: outcome, wall time and the Jev work it used. */
2300
+ export function recordCodeSearchCall(
2301
+ observability: Observability,
2302
+ input: {
2303
+ outcome: CodeSearchCallOutcome;
2304
+ durationSeconds: number;
2305
+ jevRequests: number;
2306
+ jevCostUsd: number;
2307
+ },
2308
+ ): void {
2309
+ observability.incrementCounter({
2310
+ name: "opengeni_code_search_calls_total",
2311
+ help: "Jev-backed code_search tool calls by outcome.",
2312
+ labels: { outcome: input.outcome },
2313
+ });
2314
+ observability.observeHistogram({
2315
+ name: "opengeni_code_search_duration_seconds",
2316
+ help: "Wall time of one code_search tool call.",
2317
+ buckets: [0.5, 1, 2, 4, 8, 15, 30, 60],
2318
+ labels: { outcome: input.outcome },
2319
+ value: Math.max(0, input.durationSeconds),
2320
+ });
2321
+ if (input.jevRequests > 0) {
2322
+ observability.incrementCounter({
2323
+ name: "opengeni_code_search_jev_requests_total",
2324
+ help: "Jev requests made by code_search.",
2325
+ amount: input.jevRequests,
2326
+ });
2327
+ }
2328
+ if (input.jevCostUsd > 0) {
2329
+ observability.incrementCounter({
2330
+ name: "opengeni_code_search_jev_cost_micro_usd_total",
2331
+ help: "Estimated Jev list-price cost of code_search, in micro-USD.",
2332
+ amount: Math.round(input.jevCostUsd * 1_000_000),
2333
+ });
2334
+ }
2335
+ }
@@ -295,6 +295,8 @@ export function personalGitBrokerOrigin(settings: Settings): string | null {
295
295
  (url.hostname === "host.docker.internal" ||
296
296
  url.hostname === cachedDockerBridgeGateway ||
297
297
  /^[a-z][a-z0-9-]{0,62}$/iu.test(url.hostname));
298
+ // The `local` and `test` environments accept any origin, which covers the
299
+ // local launcher's Linux Docker sandbox route on the Compose network gateway.
298
300
  const privateExecutionOrigin =
299
301
  (url.protocol === "http:" &&
300
302
  (url.hostname === "127.0.0.1" || url.hostname === "localhost" || dockerPrivateOrigin)) ||
@@ -81,6 +81,7 @@ import {
81
81
  withoutSandboxProviderIdentity,
82
82
  type EstablishedSandboxSession,
83
83
  type RuntimeMetricsHooks,
84
+ type SandboxReadinessReplacementOutcome,
84
85
  type WorkspaceArchiveDescriptor,
85
86
  } from "@opengeni/runtime";
86
87
  import {
@@ -150,6 +151,24 @@ export type SandboxResumeServices = {
150
151
  /** Test seam for the bounded command-readiness proof performed before an
151
152
  * attached provider box is handed to the agent. */
152
153
  verifyAttachedSandboxReadiness?: (established: EstablishedSandboxSession) => Promise<void>;
154
+ /** Test seam for the bounded command-readiness proof the elected spawner runs
155
+ * on its freshly created box before publishing the lease warm. Production
156
+ * uses {@link waitForSandboxExecReadiness} with the shared 60 s budget. */
157
+ verifySpawnedSandboxReadiness?: (
158
+ established: EstablishedSandboxSession,
159
+ identity: { sandboxGroupId: string },
160
+ ) => Promise<void>;
161
+ /** Test seam for the jittered pause before the single fresh-box readiness
162
+ * replacement. Production uses {@link freshSandboxReadinessReplacementDelayMs}.
163
+ * It may be async so a test can observe the rolled-back lease in between. */
164
+ freshSandboxReadinessReplacementDelayMs?: () => number | Promise<number>;
165
+ /**
166
+ * The turn attempt's fresh-box readiness replacement budget. The lazy
167
+ * provisioner may call resumeBoxForTurn again after a typed lease
168
+ * supersession, so the turn creates the budget once and shares it with every
169
+ * call. Absent, the call gets its own single replacement.
170
+ */
171
+ freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
153
172
  /** Called only by the observer that wins the exact warm->cold loss CAS. */
154
173
  onSandboxLost?: (input: {
155
174
  sandboxGroupId: string;
@@ -318,6 +337,80 @@ export class SandboxSiblingWarmingTimeoutError extends SandboxWarmingTimeoutErro
318
337
  }
319
338
  }
320
339
 
340
+ /**
341
+ * Out-of-band proof attached to one exact {@link SandboxExecReadinessTimeoutError}
342
+ * thrown by the elected spawner: the box that missed its readiness budget was
343
+ * freshly created by this caller (not an attached, resumed, or provider-continuity
344
+ * box), it was never published warm, the provider terminated it, and the holder
345
+ * was released. Only such an error may be replaced by re-entering admission.
346
+ */
347
+ const disposedUnreadyFreshSandboxErrors = new WeakSet<object>();
348
+
349
+ /** True only for a spawner readiness timeout whose unready fresh box is proven gone. */
350
+ export function isReplaceableFreshSandboxReadinessTimeout(error: unknown): boolean {
351
+ return (
352
+ error instanceof SandboxExecReadinessTimeoutError &&
353
+ disposedUnreadyFreshSandboxErrors.has(error)
354
+ );
355
+ }
356
+
357
+ /**
358
+ * A burst of simultaneous turn starts can leave many freshly created provider
359
+ * boxes command-unready at once. The single replacement re-enters admission
360
+ * after a jittered pause so the replacement creates do not re-synchronize into
361
+ * the same burst. Bounded to [2 s, 10 s).
362
+ */
363
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS = 2_000;
364
+ export const FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS = 8_000;
365
+
366
+ export function freshSandboxReadinessReplacementDelayMs(
367
+ random: () => number = Math.random,
368
+ ): number {
369
+ const sample = random();
370
+ const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
371
+ return (
372
+ FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS +
373
+ Math.floor(unit * FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS)
374
+ );
375
+ }
376
+
377
+ /** Replacements allowed per turn attempt, across every resumeBoxForTurn call. */
378
+ export const FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT = 1;
379
+
380
+ export type FreshSandboxReadinessReplacementBudget = { remaining: number };
381
+
382
+ export function createFreshSandboxReadinessReplacementBudget(): FreshSandboxReadinessReplacementBudget {
383
+ return { remaining: FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT };
384
+ }
385
+
386
+ function recordFreshSandboxReadinessReplacement(
387
+ metrics: RuntimeMetricsHooks | undefined,
388
+ backend: string,
389
+ outcome: SandboxReadinessReplacementOutcome,
390
+ ): void {
391
+ try {
392
+ metrics?.onSandboxReadinessReplacement?.({ backend, outcome });
393
+ } catch {
394
+ // Metrics emission must never affect sandbox recovery or error propagation.
395
+ }
396
+ }
397
+
398
+ async function sleepUnlessCancelled(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
399
+ if (signal?.aborted) return false;
400
+ if (ms <= 0) return true;
401
+ return await new Promise<boolean>((resolve) => {
402
+ const onAbort = () => {
403
+ clearTimeout(timer);
404
+ resolve(false);
405
+ };
406
+ const timer = setTimeout(() => {
407
+ signal?.removeEventListener("abort", onAbort);
408
+ resolve(true);
409
+ }, ms);
410
+ signal?.addEventListener("abort", onAbort, { once: true });
411
+ });
412
+ }
413
+
321
414
  /** The exact attached caller that won warm->cold after proving the provider
322
415
  * instance gone. It is a lease supersession (the same logical turn recovers),
323
416
  * plus the lost id needed for one durable observability event. */
@@ -1057,12 +1150,103 @@ async function persistWarmWorkspaceSnapshot(
1057
1150
  * holderId is the globally unique durable turn-attempt id. It must not be a
1058
1151
  * Temporal activity id, because activity ids are only workflow-local and
1059
1152
  * collide when sibling sessions share one sandbox group.
1153
+ *
1154
+ * A freshly created box that misses its bounded command-readiness budget is
1155
+ * replaced at most once per turn attempt (the budget is shared by every call
1156
+ * the turn's provisioner makes). The first establish has already terminated that exact
1157
+ * unpublished box, rolled its exact warming epoch back to cold (advancing the
1158
+ * epoch), and released its holder, so the replacement is an ordinary new
1159
+ * admission: it re-runs the cold->warming CAS, persists its own provider
1160
+ * instance before readiness, and may instead attach to a sibling that won the
1161
+ * CAS first. Nothing model- or tool-visible ran on the discarded box, so no
1162
+ * side effect is replayed. A second readiness timeout fails the turn exactly
1163
+ * as before; attached/resumed boxes and unconfirmed terminations never
1164
+ * replace.
1060
1165
  */
1061
1166
  export async function resumeBoxForTurn(
1062
1167
  services: SandboxResumeServices,
1063
1168
  ids: ResumeBoxIds,
1064
1169
  kind: "turn",
1065
1170
  holderId: TurnSandboxLeaseHolderId,
1171
+ ): Promise<ResumedTurnSandbox> {
1172
+ try {
1173
+ return await resumeBoxForTurnOnce(services, ids, kind, holderId);
1174
+ } catch (error) {
1175
+ if (!isReplaceableFreshSandboxReadinessTimeout(error)) throw error;
1176
+ const readiness = error as SandboxExecReadinessTimeoutError;
1177
+ const warn = (message: string, fields: Parameters<Observability["warn"]>[1]) => {
1178
+ if (services.observability) services.observability.warn(message, fields);
1179
+ else console.warn(message, fields);
1180
+ };
1181
+ const identity = {
1182
+ workspaceId: ids.workspaceId,
1183
+ sessionId: ids.sessionId,
1184
+ sandboxGroupId: ids.sandboxGroupId,
1185
+ backend: readiness.backend,
1186
+ instanceId: readiness.instanceId,
1187
+ readinessTimeoutMs: readiness.timeoutMs,
1188
+ };
1189
+ const budget =
1190
+ services.freshSandboxReadinessReplacementBudget ??
1191
+ createFreshSandboxReadinessReplacementBudget();
1192
+ if (budget.remaining <= 0) {
1193
+ recordFreshSandboxReadinessReplacement(
1194
+ services.sandboxMetrics,
1195
+ readiness.backend,
1196
+ "budget_spent",
1197
+ );
1198
+ warn(
1199
+ "sandbox command-readiness timed out on a fresh box; this turn attempt already used its replacement",
1200
+ identity,
1201
+ );
1202
+ throw error;
1203
+ }
1204
+ budget.remaining -= 1;
1205
+ const delayMs = await (
1206
+ services.freshSandboxReadinessReplacementDelayMs ?? freshSandboxReadinessReplacementDelayMs
1207
+ )();
1208
+ warn("sandbox command-readiness timed out on a fresh box; replacing it once after jitter", {
1209
+ ...identity,
1210
+ replacementDelayMs: delayMs,
1211
+ });
1212
+ if (!(await sleepUnlessCancelled(delayMs, services.cancellationSignal))) {
1213
+ // Cancellation owns the turn boundary; surface the original typed
1214
+ // readiness failure rather than starting a replacement.
1215
+ recordFreshSandboxReadinessReplacement(
1216
+ services.sandboxMetrics,
1217
+ readiness.backend,
1218
+ "cancelled",
1219
+ );
1220
+ throw error;
1221
+ }
1222
+ try {
1223
+ const replaced = await resumeBoxForTurnOnce(services, ids, kind, holderId);
1224
+ recordFreshSandboxReadinessReplacement(
1225
+ services.sandboxMetrics,
1226
+ readiness.backend,
1227
+ "replaced",
1228
+ );
1229
+ return replaced;
1230
+ } catch (replacementError) {
1231
+ recordFreshSandboxReadinessReplacement(
1232
+ services.sandboxMetrics,
1233
+ readiness.backend,
1234
+ services.cancellationSignal?.aborted
1235
+ ? "cancelled"
1236
+ : replacementError instanceof SandboxExecReadinessTimeoutError
1237
+ ? "failed_again"
1238
+ : "replacement_failed",
1239
+ );
1240
+ throw replacementError;
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ async function resumeBoxForTurnOnce(
1246
+ services: SandboxResumeServices,
1247
+ ids: ResumeBoxIds,
1248
+ kind: "turn",
1249
+ holderId: TurnSandboxLeaseHolderId,
1066
1250
  ): Promise<ResumedTurnSandbox> {
1067
1251
  const { db, settings } = services;
1068
1252
  const os = ids.os ?? "linux";
@@ -1656,11 +1840,17 @@ export async function resumeBoxForTurn(
1656
1840
  // A sandbox handle is not sufficient evidence that an asynchronous
1657
1841
  // provider's command router is live. Do not publish a warm lease until
1658
1842
  // one bounded no-op exec works.
1659
- // On timeout the catch below terminates the box and rolls warming -> cold,
1660
- // so the next turn cold-creates instead of hanging forever on first use.
1661
- await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1662
- sandboxGroupId: ids.sandboxGroupId,
1663
- });
1843
+ // On timeout the catch below terminates the box and rolls warming -> cold;
1844
+ // resumeBoxForTurn may then replace a proven-terminated fresh box once.
1845
+ if (services.verifySpawnedSandboxReadiness) {
1846
+ await services.verifySpawnedSandboxReadiness(established, {
1847
+ sandboxGroupId: ids.sandboxGroupId,
1848
+ });
1849
+ } else {
1850
+ await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
1851
+ sandboxGroupId: ids.sandboxGroupId,
1852
+ });
1853
+ }
1664
1854
  await maybeRenewProviderExpiration(true);
1665
1855
  throwIfReleasedOrCancelled();
1666
1856
  // Fold the LIVE box into a re-resumable envelope and persist it as the
@@ -1796,6 +1986,16 @@ export async function resumeBoxForTurn(
1796
1986
  }
1797
1987
  await release();
1798
1988
  recordSandboxWarmingTimeout(services.sandboxMetrics, error);
1989
+ if (
1990
+ terminated &&
1991
+ createdEstablished &&
1992
+ error instanceof SandboxExecReadinessTimeoutError &&
1993
+ error.instanceId === createdEstablished.instanceId &&
1994
+ (createdEstablished.origin === "created" || createdEstablished.origin === "restored") &&
1995
+ !createdEstablished.providerContinuity
1996
+ ) {
1997
+ disposedUnreadyFreshSandboxErrors.add(error);
1998
+ }
1799
1999
  throw sandboxProvisionStageError(rematerialization ? "archive_recovery" : "create", error);
1800
2000
  }
1801
2001
  }