@opengeni/worker-bundle 2.3.1 → 2.3.2-canary.37090756480001

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 (66) hide show
  1. package/dist/activities/agent-turn/claude-usage-observer.d.ts +30 -6
  2. package/dist/activities/agent-turn/credential-leases.d.ts +5 -32
  3. package/dist/activities/agent-turn/errors.d.ts +3 -0
  4. package/dist/activities/agent-turn/finalization-monitor.d.ts +1 -1
  5. package/dist/activities/agent-turn/finalization.d.ts +2 -0
  6. package/dist/activities/agent-turn/quiescence.d.ts +3 -4
  7. package/dist/activities/agent-turn/subscription-lease.d.ts +38 -0
  8. package/dist/activities/agent-turn/turn-context.d.ts +1 -1
  9. package/dist/activities/model-call-fact-reconciler.d.ts +34 -0
  10. package/dist/activities/session-state.d.ts +2 -2
  11. package/dist/activities/types.d.ts +7 -0
  12. package/dist/{activities-control-3HAH4FMG.js → activities-control-3BY4IJKL.js} +166 -35
  13. package/dist/activities-control-3BY4IJKL.js.map +1 -0
  14. package/dist/activities-control.d.ts +6 -4
  15. package/dist/{activities-turn-KU25J3VQ.js → activities-turn-TTNAVGKN.js} +263 -308
  16. package/dist/activities-turn-TTNAVGKN.js.map +1 -0
  17. package/dist/activities.d.ts +6 -4
  18. package/dist/{chunk-3K3ULWEE.js → chunk-PA7Z55P4.js} +242 -17
  19. package/dist/chunk-PA7Z55P4.js.map +1 -0
  20. package/dist/{chunk-YG27NEMX.js → chunk-YOI3Z3GF.js} +9 -1
  21. package/dist/chunk-YOI3Z3GF.js.map +1 -0
  22. package/dist/http.d.ts +1 -1
  23. package/dist/index.d.ts +6 -2
  24. package/dist/index.js +82 -16
  25. package/dist/index.js.map +1 -1
  26. package/dist/observability-metrics.d.ts +6 -1
  27. package/dist/sandbox-resume.d.ts +3 -0
  28. package/dist/session-event-fanout.d.ts +6 -0
  29. package/dist/worker-service-lifecycle.d.ts +12 -0
  30. package/dist/workflow-bundle.js +3 -2
  31. package/package.json +21 -21
  32. package/src/activities/agent-turn/claim.ts +13 -0
  33. package/src/activities/agent-turn/claude-usage-observer.ts +99 -8
  34. package/src/activities/agent-turn/credential-leases.ts +86 -213
  35. package/src/activities/agent-turn/errors.ts +61 -1
  36. package/src/activities/agent-turn/failure-settlement.ts +59 -5
  37. package/src/activities/agent-turn/finalization-monitor.ts +3 -4
  38. package/src/activities/agent-turn/finalization.ts +8 -3
  39. package/src/activities/agent-turn/quiescence.ts +3 -4
  40. package/src/activities/agent-turn/run.ts +24 -20
  41. package/src/activities/agent-turn/sandbox-establish.ts +2 -0
  42. package/src/activities/agent-turn/sandbox-runtime.ts +5 -1
  43. package/src/activities/agent-turn/skill-selection.ts +2 -0
  44. package/src/activities/agent-turn/subscription-lease.ts +115 -0
  45. package/src/activities/agent-turn/turn-context.ts +1 -4
  46. package/src/activities/codex-rotation.ts +2 -12
  47. package/src/activities/model-call-fact-reconciler.ts +112 -0
  48. package/src/activities/sandbox-lease.ts +59 -8
  49. package/src/activities/session-state.ts +29 -4
  50. package/src/activities/types.ts +7 -0
  51. package/src/activities-control.ts +2 -0
  52. package/src/activities-turn.ts +5 -0
  53. package/src/activities.ts +7 -0
  54. package/src/http.ts +4 -2
  55. package/src/index.ts +39 -9
  56. package/src/observability-metrics.ts +27 -0
  57. package/src/sandbox-resume.ts +9 -0
  58. package/src/sandbox-routing.ts +77 -0
  59. package/src/session-event-fanout.ts +23 -0
  60. package/src/worker-service-lifecycle.ts +53 -0
  61. package/src/workflows/file-upload-reaper.ts +2 -0
  62. package/src/workflows/session.ts +6 -1
  63. package/dist/activities-control-3HAH4FMG.js.map +0 -1
  64. package/dist/activities-turn-KU25J3VQ.js.map +0 -1
  65. package/dist/chunk-3K3ULWEE.js.map +0 -1
  66. package/dist/chunk-YG27NEMX.js.map +0 -1
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { listWorkspaceIdsAfter, reconcileModelCallFacts } from "@opengeni/db";
3
+ import type { ControlActivityServices } from "./types";
4
+
5
+ /** Recent charged calls are rechecked on every run; older gaps use the backfill script. */
6
+ export const MODEL_CALL_FACT_RECONCILE_LOOKBACK_MS = 2 * 60 * 60 * 1_000;
7
+ /** Skip the newest calls so a live fact write is not raced by its own repair. */
8
+ export const MODEL_CALL_FACT_RECONCILE_SETTLE_MS = 10 * 60 * 1_000;
9
+ export const MODEL_CALL_FACT_RECONCILE_WORKSPACE_LIMIT = 500;
10
+ export const MODEL_CALL_FACT_RECONCILE_RUN_BUDGET_MS = 3 * 60 * 1_000;
11
+ const WORKSPACE_PAGE_SIZE = 200;
12
+
13
+ export type ReconcileRecentModelCallFactsResult = {
14
+ workspaces: number;
15
+ missing: number;
16
+ repaired: number;
17
+ unrepaired: number;
18
+ /** Workspaces with more missing facts than one bounded pass examines. */
19
+ truncatedWorkspaces: number;
20
+ failedWorkspaces: number;
21
+ budgetExhausted: boolean;
22
+ };
23
+
24
+ export type ModelCallFactReconcilerOptions = {
25
+ now?: () => number;
26
+ runBudgetMs?: number;
27
+ startAfterWorkspaceId?: () => string;
28
+ reconcile?: typeof reconcileModelCallFacts;
29
+ listWorkspaces?: typeof listWorkspaceIdsAfter;
30
+ };
31
+
32
+ /**
33
+ * Rebuild missing Insights call facts for recently charged model calls. Each
34
+ * workspace pass is bounded and idempotent, one failure never stops the rest,
35
+ * and each run starts at a random workspace so a run that exhausts its budget
36
+ * does not starve the same tail of workspaces every time.
37
+ */
38
+ export function createModelCallFactReconcilerActivities(
39
+ services: () => Promise<ControlActivityServices>,
40
+ options: ModelCallFactReconcilerOptions = {},
41
+ ) {
42
+ const now = options.now ?? Date.now;
43
+ const runBudgetMs = options.runBudgetMs ?? MODEL_CALL_FACT_RECONCILE_RUN_BUDGET_MS;
44
+ const startAfterWorkspaceId = options.startAfterWorkspaceId ?? randomUUID;
45
+ const reconcile = options.reconcile ?? reconcileModelCallFacts;
46
+ const listWorkspaces = options.listWorkspaces ?? listWorkspaceIdsAfter;
47
+
48
+ async function reconcileRecentModelCallFacts(): Promise<ReconcileRecentModelCallFactsResult> {
49
+ const { db, observability } = await services();
50
+ const startedAt = now();
51
+ const until = new Date(startedAt - MODEL_CALL_FACT_RECONCILE_SETTLE_MS);
52
+ const since = new Date(until.getTime() - MODEL_CALL_FACT_RECONCILE_LOOKBACK_MS);
53
+ const result: ReconcileRecentModelCallFactsResult = {
54
+ workspaces: 0,
55
+ missing: 0,
56
+ repaired: 0,
57
+ unrepaired: 0,
58
+ truncatedWorkspaces: 0,
59
+ failedWorkspaces: 0,
60
+ budgetExhausted: false,
61
+ };
62
+ const start = startAfterWorkspaceId();
63
+ let cursor: string | null = start;
64
+ let wrapped = false;
65
+ while (true) {
66
+ const page = await listWorkspaces(db, { afterId: cursor, limit: WORKSPACE_PAGE_SIZE });
67
+ const ids = wrapped ? page.filter((id) => id <= start) : page;
68
+ for (const workspaceId of ids) {
69
+ if (now() - startedAt >= runBudgetMs) {
70
+ result.budgetExhausted = true;
71
+ break;
72
+ }
73
+ result.workspaces += 1;
74
+ try {
75
+ const outcome = await reconcile(db, {
76
+ workspaceId,
77
+ since,
78
+ until,
79
+ limit: MODEL_CALL_FACT_RECONCILE_WORKSPACE_LIMIT,
80
+ });
81
+ result.missing += outcome.missing;
82
+ result.repaired += outcome.repaired;
83
+ result.unrepaired += outcome.unrepaired;
84
+ if (outcome.truncated) result.truncatedWorkspaces += 1;
85
+ } catch (error) {
86
+ result.failedWorkspaces += 1;
87
+ observability.warn("model call fact reconciliation failed for a workspace", {
88
+ workspaceId,
89
+ errorName: error instanceof Error ? error.name : "unknown",
90
+ });
91
+ }
92
+ }
93
+ if (result.budgetExhausted) break;
94
+ if (wrapped && ids.length < page.length) break;
95
+ if (page.length < WORKSPACE_PAGE_SIZE) {
96
+ if (wrapped) break;
97
+ wrapped = true;
98
+ cursor = null;
99
+ continue;
100
+ }
101
+ cursor = page[page.length - 1]!;
102
+ }
103
+ if (result.failedWorkspaces > 0 || result.truncatedWorkspaces > 0 || result.budgetExhausted) {
104
+ observability.warn("model call fact reconciliation incomplete", { ...result });
105
+ } else if (result.missing > 0) {
106
+ observability.info("model call fact reconciliation", { ...result });
107
+ }
108
+ return result;
109
+ }
110
+
111
+ return { reconcileRecentModelCallFacts };
112
+ }
@@ -14,6 +14,7 @@
14
14
  // timer, viewer activity, owner task queue, or provider-specific lifecycle path
15
15
  // in the normal drain state machine.
16
16
  import { warnDrainSnapshotFailure } from "../sandbox-snapshot-diagnostics";
17
+ import { publishDurableSessionEvents } from "../session-event-fanout";
17
18
  import { warnRetainedProcessProofFailure } from "../retained-process-diagnostics";
18
19
  import { retainedProcessDeadlineRetryMs } from "../retained-process-retry";
19
20
 
@@ -68,6 +69,7 @@ import {
68
69
  workspaceArchiveCaptureDeadlineElapsed,
69
70
  retainedProcessReconciliationProof,
70
71
  retainedProcessSettlementIdentity,
72
+ recoverManagedSessionBackgroundCommand,
71
73
  settleClaimedConnectedMachineBackgroundCommand,
72
74
  settleSandboxCheckpointArtifactGc,
73
75
  rlsContextForWorkspace,
@@ -169,6 +171,7 @@ import {
169
171
  type SandboxInventoryProjectionDomain,
170
172
  recordSandboxLeaseGauges,
171
173
  recordSandboxOrphansTerminated,
174
+ recordSandboxCommandContainment,
172
175
  recordSandboxProviderMissingBeforeCapture,
173
176
  recordSandboxRecoveryObservationGauges,
174
177
  recordSandboxRotationBacklogGauges,
@@ -562,10 +565,15 @@ export function createSandboxLeaseActivities(
562
565
  try {
563
566
  const { db, settings, observability } = await services();
564
567
  const timing = sandboxDrainTiming(settings);
565
- const onUnobservableCommandDrainError = (error: unknown) => {
566
- observability.warn("sandbox reaper: unobservable command drain inspection failed", {
567
- error: error instanceof Error ? error.message : String(error),
568
- });
568
+ const commandContainment = {
569
+ idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
570
+ onCommandContainment: (outcome: Parameters<typeof recordSandboxCommandContainment>[1]) =>
571
+ recordSandboxCommandContainment(observability, outcome),
572
+ onCommandContainmentError: (error: unknown) => {
573
+ observability.warn("sandbox reaper: retained command containment inspection failed", {
574
+ error: error instanceof Error ? error.message : String(error),
575
+ });
576
+ },
569
577
  };
570
578
  if (!settings.sandboxOwnershipEnabled) {
571
579
  // Turns skip leases when the flag is off, but Computer/Browser attach
@@ -573,7 +581,7 @@ export function createSandboxLeaseActivities(
573
581
  // behind rotation_in_progress forever. Inventory and drain those rows;
574
582
  // do not request NEW deadline rotations or meter warm time.
575
583
  const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
576
- onUnobservableCommandDrainError,
584
+ ...commandContainment,
577
585
  viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
578
586
  turnHolderTtlMs: settings.sandboxLeaseTtlMs,
579
587
  interactionHolderTtlMs: settings.sandboxInteractionHolderTtlMs,
@@ -610,7 +618,7 @@ export function createSandboxLeaseActivities(
610
618
  // Billing, reconciliation, provider-orphan cleanup, artifact GC, and gauges
611
619
  // run only after every drainable box has its own durable child.
612
620
  const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
613
- onUnobservableCommandDrainError,
621
+ ...commandContainment,
614
622
  viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
615
623
  // Dead-worker turn holders: a live holder is touched every 10s from the
616
624
  // moment it is registered (resumeBoxForTurn's holder-liveness loop covers
@@ -654,7 +662,7 @@ export function createSandboxLeaseActivities(
654
662
  instanceId: input.target.instanceId,
655
663
  });
656
664
  try {
657
- const { db, settings, observability, objectStorage } = await services();
665
+ const { db, settings, observability, objectStorage, bus } = await services();
658
666
  assertSandboxDrainInputTiming(input);
659
667
  const drainSettings =
660
668
  settings.sandboxSnapshotTimeoutMs === input.snapshotTimeoutMs
@@ -678,6 +686,7 @@ export function createSandboxLeaseActivities(
678
686
  probeDrainableProvider,
679
687
  captureAttempt,
680
688
  objectStorage,
689
+ bus,
681
690
  );
682
691
  return { status: drainedCold ? "terminated" : "skipped" };
683
692
  } catch (error) {
@@ -1477,6 +1486,32 @@ async function reconcileTerminalRetainedProcesses(
1477
1486
  sessionId: process.sessionId,
1478
1487
  processId: process.id,
1479
1488
  };
1489
+ // Recover a yield whose observation failed before normal receipt adoption.
1490
+ // Do this before provider I/O: an unavailable observer must not fence every
1491
+ // subsequent turn. Physical retention and unknown outcome stay unchanged.
1492
+ if (
1493
+ process.ownerActorKind === "turn" &&
1494
+ process.providerBackend === "modal" &&
1495
+ !proof &&
1496
+ !claim.ownerState.startsWith("background_")
1497
+ ) {
1498
+ try {
1499
+ const command = await recoverManagedSessionBackgroundCommand(db, {
1500
+ ...processScope,
1501
+ expected,
1502
+ reconciliationClaimId: claim.claimId,
1503
+ });
1504
+ if (command) {
1505
+ claim.ownerState = `background_${command.state}`;
1506
+ recordRetainedProcessReconciliation(observability, "background_recovered");
1507
+ }
1508
+ } catch (error) {
1509
+ observability.warn("sandbox reaper: retained-command background recovery failed", {
1510
+ processId: process.id,
1511
+ error: error instanceof Error ? error.message : String(error),
1512
+ });
1513
+ }
1514
+ }
1480
1515
  const storedCommandPersistence = retainedProviderCommandPersistence(
1481
1516
  db,
1482
1517
  processScope,
@@ -2858,6 +2893,7 @@ async function terminateDrainableBox(
2858
2893
  probeDrainableProvider: DrainableProviderProbeFn,
2859
2894
  attempt: SandboxDrainCaptureAttempt,
2860
2895
  objectStorage: ObjectStorage | null,
2896
+ bus: ActivityServices["bus"] | null = null,
2861
2897
  ): Promise<boolean> {
2862
2898
  // Resolve the account for the RLS-scoped confirmDrainCold (the global sweep
2863
2899
  // returns no account_id; the workspace->account map is the bootstrap read).
@@ -3318,13 +3354,22 @@ async function terminateDrainableBox(
3318
3354
  // with draining->cold. Until this succeeds, arrivals remain fenced by that
3319
3355
  // exact claim; a timestamp or a failed provider call can never reopen a box
3320
3356
  // while termination may still be in flight.
3321
- const { wentCold } = await confirmDrainCold(db, {
3357
+ const { wentCold, backgroundCommandEvents } = await confirmDrainCold(db, {
3322
3358
  accountId,
3323
3359
  workspaceId: row.workspaceId,
3324
3360
  sandboxGroupId: row.sandboxGroupId,
3325
3361
  expectedEpoch: row.leaseEpoch,
3326
3362
  ...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
3327
3363
  providerMissingBeforeCapture: providerMissing,
3364
+ idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
3365
+ });
3366
+ // The command terminal events and agent inputs are already durable in the
3367
+ // cold commit; this is only best-effort live fanout.
3368
+ await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
3369
+ observability.warn("sandbox reaper: contained command event fanout failed", {
3370
+ sandboxGroupId: row.sandboxGroupId,
3371
+ error: error instanceof Error ? error.message : String(error),
3372
+ });
3328
3373
  });
3329
3374
  if (wentCold) {
3330
3375
  // Only the exact successful cold commit counts provider loss. A missing
@@ -3333,6 +3378,12 @@ async function terminateDrainableBox(
3333
3378
  if (providerMissing) {
3334
3379
  recordSandboxProviderMissingBeforeCapture(observability, backend);
3335
3380
  }
3381
+ if (lease.unobservableCommandDrainIds?.length) {
3382
+ recordSandboxCommandContainment(
3383
+ observability,
3384
+ providerMissing ? "provider_missing" : "contained",
3385
+ );
3386
+ }
3336
3387
  // Durable termination record (sandbox-file-persistence observability): who
3337
3388
  // ended this box and whether its /workspace was captured first, appended to
3338
3389
  // every session sharing the group's box. Best-effort: attribution must
@@ -19,6 +19,7 @@ import {
19
19
  markSessionAttemptQuiesced,
20
20
  requireSession,
21
21
  settleSessionIdleWithParentOutbox,
22
+ SANDBOX_SETUP_RECOVERY_LIMIT,
22
23
  } from "@opengeni/db";
23
24
  import { publishDurableSessionEvents } from "@opengeni/events";
24
25
  import { CancelledFailure } from "@temporalio/activity";
@@ -239,6 +240,9 @@ export function createSessionStateActivities(
239
240
  turn.triggerEventId === postClaimRecovery.triggerEventId &&
240
241
  turn.executionGeneration === postClaimRecovery.executionGeneration,
241
242
  );
243
+ if (postClaimRecovery?.sandboxSetupRecoveryExhausted && !postClaimIdentityMatches) {
244
+ return { action: "stale" };
245
+ }
242
246
  const providerRecoveryCount = postClaimIdentityMatches
243
247
  ? postClaimRecovery?.providerRecoveryCount
244
248
  : undefined;
@@ -249,7 +253,15 @@ export function createSessionStateActivities(
249
253
  const hasProviderFailureCode = providerFailureCode !== undefined;
250
254
  const setupOutcomeUnknown =
251
255
  postClaimIdentityMatches && postClaimRecovery?.sandboxSetupOutcomeUnknown === true;
252
- if (setupOutcomeUnknown && (hasProviderRecoveryCount || hasProviderFailureCode)) {
256
+ const setupRecoveryExhausted =
257
+ postClaimIdentityMatches && postClaimRecovery?.sandboxSetupRecoveryExhausted === true;
258
+ if (
259
+ (setupOutcomeUnknown && setupRecoveryExhausted) ||
260
+ ((setupOutcomeUnknown || setupRecoveryExhausted) &&
261
+ (hasProviderRecoveryCount || hasProviderFailureCode)) ||
262
+ (setupRecoveryExhausted &&
263
+ turn.metadata?.providerRecoveryCount !== SANDBOX_SETUP_RECOVERY_LIMIT)
264
+ ) {
253
265
  return { action: "stale" };
254
266
  }
255
267
  if (hasProviderRecoveryCount !== hasProviderFailureCode) {
@@ -282,15 +294,28 @@ export function createSessionStateActivities(
282
294
  attemptId: input.attemptId,
283
295
  reason: setupOutcomeUnknown
284
296
  ? "sandbox_command_start_outcome_unknown"
285
- : (providerFailureCode ?? "claimed_attempt_database_failure"),
297
+ : setupRecoveryExhausted
298
+ ? "sandbox_command_start_recovery_exhausted"
299
+ : (providerFailureCode ?? "claimed_attempt_database_failure"),
286
300
  ...(setupOutcomeUnknown ? { sandboxSetupOutcomeUnknown: true } : {}),
301
+ ...(setupRecoveryExhausted ? { sandboxSetupRecoveryExhausted: true } : {}),
287
302
  ...(providerRecoveryCount !== undefined ? { providerRecoveryCount } : {}),
288
303
  detail: {
289
304
  code: setupOutcomeUnknown
290
305
  ? "sandbox_command_start_outcome_unknown"
291
- : (providerFailureCode ?? recoveredClaimCode),
292
- retryable: !setupOutcomeUnknown,
306
+ : setupRecoveryExhausted
307
+ ? "sandbox_command_start_recovery_exhausted"
308
+ : (providerFailureCode ?? recoveredClaimCode),
309
+ retryable: !setupOutcomeUnknown && !setupRecoveryExhausted,
293
310
  ...(setupOutcomeUnknown ? { setupOutcome: "unknown", replay: "blocked" } : {}),
311
+ ...(setupRecoveryExhausted
312
+ ? {
313
+ setupOutcome: "not_started",
314
+ replay: "blocked",
315
+ recoveryExhausted: true,
316
+ providerRecoveryCount: SANDBOX_SETUP_RECOVERY_LIMIT,
317
+ }
318
+ : {}),
294
319
  ...(providerRecoveryCount !== undefined
295
320
  ? {
296
321
  databaseFailureCode: recoveredClaimCode,
@@ -144,6 +144,11 @@ export type ControlActivityServices = SharedActivityServices;
144
144
 
145
145
  /** Turn workers own the model loop and never construct document parsers. */
146
146
  export type TurnActivityServices = SharedActivityServices & {
147
+ /** Host-owned containment: stop polling and checkpoint peer turns before exit.
148
+ * Bare activity embedders must provide their own worker lifecycle edge. */
149
+ requestWorkerDrain: () => void;
150
+ /** Deterministic test clock for the cleanup-only monitor. */
151
+ turnFinalizationTimeoutMs?: number | undefined;
147
152
  runtime: OpenGeniRuntime;
148
153
  /** Provider-free test/profiling seam; production injects the real runtime summarizer. */
149
154
  summarizeContextForCompaction: typeof import("@opengeni/runtime").summarizeForCompaction;
@@ -304,6 +309,8 @@ export type PostClaimDatabaseRecoveryDetail = {
304
309
  providerFailureCode?: string;
305
310
  /** Persist the no-replay setup marker, not a new provider/setup attempt. */
306
311
  sandboxSetupOutcomeUnknown?: true;
312
+ /** Preserve the exhausted budget for a command proven not dispatched. */
313
+ sandboxSetupRecoveryExhausted?: true;
307
314
  };
308
315
 
309
316
  export const POST_CLAIM_DATABASE_RECOVERY_FAILURE_TYPE = "OpenGeniPostClaimDatabaseRecovery";
@@ -7,6 +7,7 @@ import { createAutomationActivities } from "./activities/automations";
7
7
  import { createDocumentActivities } from "./activities/documents";
8
8
  import { createFileUploadReaperActivities } from "./activities/file-upload-reaper";
9
9
  import { createGoalActivities } from "./activities/goals";
10
+ import { createModelCallFactReconcilerActivities } from "./activities/model-call-fact-reconciler";
10
11
  import { createKnowledgeSourceSyncActivities } from "./activities/knowledge-source-sync";
11
12
  import { createRetainedScreenshotMaintenanceActivities } from "./activities/retained-screenshot-reaper";
12
13
  import { createRigVerificationActivities } from "./activities/rig-verification";
@@ -42,6 +43,7 @@ export function createControlActivitiesFromServices(
42
43
  ...createBrowserStateArtifactMaintenanceActivities(services),
43
44
  ...createBrowserDeadlineCheckpointActivities(services),
44
45
  ...createFileUploadReaperActivities(services),
46
+ ...createModelCallFactReconcilerActivities(services),
45
47
  ...createRetainedScreenshotMaintenanceActivities(services),
46
48
  ...createWorkflowWakeActivities(services),
47
49
  ...createSandboxLeaseActivities(services),
@@ -12,6 +12,9 @@ function createTurnActivityServices(
12
12
  let servicesPromise: Promise<TurnActivityServices> | null = null;
13
13
  return async () => {
14
14
  servicesPromise ??= (async () => {
15
+ if (!dependencies.requestWorkerDrain) {
16
+ throw new Error("Turn activities require a host requestWorkerDrain lifecycle edge");
17
+ }
15
18
  const services = await shared();
16
19
  return {
17
20
  ...services,
@@ -20,6 +23,8 @@ function createTurnActivityServices(
20
23
  createProductionAgentRuntime({
21
24
  metrics: runtimeMetricsHooksForObservability(services.observability),
22
25
  }),
26
+ requestWorkerDrain: dependencies.requestWorkerDrain,
27
+ turnFinalizationTimeoutMs: dependencies.turnFinalizationTimeoutMs,
23
28
  summarizeContextForCompaction:
24
29
  dependencies.summarizeContextForCompaction ?? summarizeForCompaction,
25
30
  };
package/src/activities.ts CHANGED
@@ -69,6 +69,12 @@ export function createActivityTestHarness(dependencies: ActivityDependencies = {
69
69
  createProductionAgentRuntime({
70
70
  metrics: runtimeMetricsHooksForObservability(common.observability),
71
71
  }),
72
+ requestWorkerDrain:
73
+ dependencies.requestWorkerDrain ??
74
+ (() => {
75
+ throw new Error("Test host must provide requestWorkerDrain for stalled cleanup");
76
+ }),
77
+ turnFinalizationTimeoutMs: dependencies.turnFinalizationTimeoutMs,
72
78
  summarizeContextForCompaction:
73
79
  dependencies.summarizeContextForCompaction ?? summarizeForCompaction,
74
80
  documentServices: dependencies.documentServices ?? createDocumentServices(common.settings),
@@ -128,6 +134,7 @@ export const recoverVideoGenerationWorkflows =
128
134
  defaultControlActivities.recoverVideoGenerationWorkflows;
129
135
  export const maintainRetainedScreenshots = defaultControlActivities.maintainRetainedScreenshots;
130
136
  export const maintainBrowserStateArtifacts = defaultControlActivities.maintainBrowserStateArtifacts;
137
+ export const reconcileRecentModelCallFacts = defaultControlActivities.reconcileRecentModelCallFacts;
131
138
  export const maintainSiteAuthConnections = defaultControlActivities.maintainSiteAuthConnections;
132
139
  export const dispatchSessionWorkflowWakes = defaultControlActivities.dispatchSessionWorkflowWakes;
133
140
  export const verifyRigChange = defaultControlActivities.verifyRigChange;
package/src/http.ts CHANGED
@@ -120,6 +120,7 @@ export async function runReadinessChecks(
120
120
  export function dbReadyCheck(
121
121
  db: Database,
122
122
  posture?: RuntimeDatabasePostureOptions,
123
+ afterDatabaseReady?: () => Promise<void>,
123
124
  ): () => Promise<void> {
124
125
  let inFlight: Promise<void> | undefined;
125
126
  return () => {
@@ -130,9 +131,10 @@ export function dbReadyCheck(
130
131
  inFlight = (async () => {
131
132
  if (posture) {
132
133
  await assertRuntimeDatabasePosture(db, posture);
133
- return;
134
+ } else {
135
+ await db.execute(dbSql`select 1`);
134
136
  }
135
- await db.execute(dbSql`select 1`);
137
+ await afterDatabaseReady?.();
136
138
  })().finally(() => {
137
139
  inFlight = undefined;
138
140
  });
package/src/index.ts CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  combineWorkerRunTargets,
86
86
  constructWithOwnedConnection,
87
87
  createWorkerServiceLifecycle,
88
+ createWorkerCleanupContainment,
88
89
  type WorkerServiceLifecycle,
89
90
  type WorkerRunTarget,
90
91
  } from "./worker-service-lifecycle";
@@ -271,10 +272,20 @@ export async function createOpenGeniWorker(options: WorkerOptions): Promise<{
271
272
  },
272
273
  ),
273
274
  async (connection) => {
275
+ let turnWorker: Worker | undefined;
276
+ let cleanupDrainRequested = false;
274
277
  const activityDependencies = {
275
278
  ...options.activityDependencies,
276
279
  settings,
277
280
  observability,
281
+ requestWorkerDrain:
282
+ options.activityDependencies?.requestWorkerDrain ??
283
+ (() => {
284
+ if (cleanupDrainRequested) return;
285
+ if (!turnWorker) throw new Error("turn worker lifecycle is not initialized");
286
+ turnWorker.shutdown();
287
+ cleanupDrainRequested = true;
288
+ }),
278
289
  };
279
290
  const activities =
280
291
  options.activities ??
@@ -323,6 +334,7 @@ export async function createOpenGeniWorker(options: WorkerOptions): Promise<{
323
334
  : turnConcurrency!.options),
324
335
  });
325
336
  if (options.role !== "control") {
337
+ turnWorker = worker;
326
338
  turnConcurrency?.admission?.finalizeStartupBaseline();
327
339
  return { worker, connection };
328
340
  }
@@ -822,6 +834,9 @@ export async function registerSessionWorkflowWakeDispatcherSchedule(
822
834
 
823
835
  export type OpenGeniWorkerServiceOptions = Omit<WorkerOptions, "activityDependencies"> & {
824
836
  activityDependencies: ActivityDependencies & { db: Database; bus: EventBus };
837
+ /** Host-owned final containment after a stalled cleanup has drained peers.
838
+ * The standalone process exits; embedded hosts own their termination policy. */
839
+ terminateWorker?: () => void;
825
840
  /**
826
841
  * Exact catalog posture required by the standalone runtime. Embedded hosts
827
842
  * may omit this when they own an equivalent database isolation contract.
@@ -878,6 +893,7 @@ export async function createOpenGeniWorkerService(
878
893
  const onRetry = (event: Parameters<typeof logStartupDependencyRetry>[1]) =>
879
894
  logStartupDependencyRetry(observability, event);
880
895
  let lifecycle: WorkerServiceLifecycle | undefined;
896
+ let cleanupContainment: ReturnType<typeof createWorkerCleanupContainment> | undefined;
881
897
  let signaler: Awaited<ReturnType<typeof createWorkerWorkflowSignaler>> | undefined;
882
898
  let workerBundle: Awaited<ReturnType<typeof createOpenGeniWorker>> | undefined;
883
899
  let turnCapacityMonitor: ReturnType<typeof startTurnCapacityMonitor> | undefined;
@@ -957,6 +973,10 @@ export async function createOpenGeniWorkerService(
957
973
  signalCodexCapacityWorkflow,
958
974
  startSandboxReaperWorkflow,
959
975
  startVideoGenerationWorkflow,
976
+ requestWorkerDrain: () => {
977
+ if (!cleanupContainment) throw new Error("worker cleanup containment is not initialized");
978
+ cleanupContainment.request();
979
+ },
960
980
  },
961
981
  });
962
982
 
@@ -1018,18 +1038,15 @@ export async function createOpenGeniWorkerService(
1018
1038
  }
1019
1039
 
1020
1040
  if (options.http !== false) {
1021
- const databaseReady = dbReadyCheck(
1022
- options.http?.readinessDb ?? options.activityDependencies.db,
1023
- options.databasePosture,
1024
- );
1041
+ const readinessDb = options.http?.readinessDb ?? options.activityDependencies.db;
1042
+ const databaseReady = dbReadyCheck(readinessDb, options.databasePosture, async () => {
1043
+ await resolveCatalogSettings(readinessDb, settings);
1044
+ });
1025
1045
  httpServer = startWorkerHttpServer({
1026
1046
  settings,
1027
1047
  observability,
1028
1048
  checks: {
1029
- db: async () => {
1030
- await databaseReady();
1031
- await resolveCatalogSettings(options.activityDependencies.db, settings);
1032
- },
1049
+ db: databaseReady,
1033
1050
  nats: natsReadyCheck(options.activityDependencies.bus),
1034
1051
  temporal: temporalReadyCheck(workerBundle.connection),
1035
1052
  },
@@ -1056,9 +1073,21 @@ export async function createOpenGeniWorkerService(
1056
1073
  throw new Error("OpenGeni worker service initialization did not complete");
1057
1074
  }
1058
1075
 
1076
+ cleanupContainment = createWorkerCleanupContainment({
1077
+ drain: () => lifecycle?.drain("stalled turn finalization") ?? false,
1078
+ ...(options.terminateWorker ? { terminate: options.terminateWorker } : {}),
1079
+ observability,
1080
+ });
1081
+ const activeCleanupContainment = cleanupContainment;
1059
1082
  lifecycle = createWorkerServiceLifecycle({
1060
1083
  role: options.role,
1061
- worker: activeWorkerBundle.worker,
1084
+ worker: {
1085
+ shutdown: () => activeWorkerBundle.worker.shutdown(),
1086
+ run: async () => {
1087
+ await activeWorkerBundle.worker.run();
1088
+ activeCleanupContainment.finished();
1089
+ },
1090
+ },
1062
1091
  observability,
1063
1092
  closeOwnedResources: async () => {
1064
1093
  memoryPressureGuard?.close();
@@ -1210,6 +1239,7 @@ export async function startWorker() {
1210
1239
  );
1211
1240
  await runOpenGeniWorker({
1212
1241
  role,
1242
+ terminateWorker: () => process.exit(1),
1213
1243
  settings,
1214
1244
  databasePosture,
1215
1245
  http: { readinessDb: readinessDbClient.db },
@@ -1285,6 +1285,32 @@ export function recordSandboxDeadlineRotationsRequested(
1285
1285
  });
1286
1286
  }
1287
1287
 
1288
+ /** Fixed outcomes of legacy retained-command containment: per-candidate
1289
+ * enrollment inspection, then the enrolled drain's exact cold commit. */
1290
+ export const SANDBOX_COMMAND_CONTAINMENT_OUTCOMES = [
1291
+ "idle_enrolled",
1292
+ "deadline_enrolled",
1293
+ "resumed_enrolled",
1294
+ "not_eligible",
1295
+ "inspection_failed",
1296
+ "contained",
1297
+ "provider_missing",
1298
+ ] as const;
1299
+
1300
+ export type SandboxCommandContainmentOutcome =
1301
+ (typeof SANDBOX_COMMAND_CONTAINMENT_OUTCOMES)[number];
1302
+
1303
+ export function recordSandboxCommandContainment(
1304
+ observability: Observability,
1305
+ outcome: SandboxCommandContainmentOutcome,
1306
+ ): void {
1307
+ observability.incrementCounter({
1308
+ name: "opengeni_sandbox_command_containment_total",
1309
+ help: "Legacy retained-command containment inspections and enrolled drain commits by fixed outcome.",
1310
+ labels: { outcome },
1311
+ });
1312
+ }
1313
+
1288
1314
  /** Only call after the exact draining->cold commit reports wentCold. The
1289
1315
  * backend is validated against the closed contract so provider IDs and other
1290
1316
  * per-sandbox values can never become metric labels. */
@@ -1471,6 +1497,7 @@ export function recordExpiredDrainingSandboxLeaseGauges(
1471
1497
 
1472
1498
  export const RETAINED_PROCESS_RECONCILIATION_OUTCOMES = [
1473
1499
  "claim_failed",
1500
+ "background_recovered",
1474
1501
  "proof_exited",
1475
1502
  "proof_lost",
1476
1503
  "proof_checkpoint_failed",
@@ -20,6 +20,8 @@
20
20
  //
21
21
  // Liveness between turns is the lease refcount; there is no keepalive loop.
22
22
 
23
+ import type { EventBus } from "@opengeni/events";
24
+ import { publishDurableSessionEvents } from "./session-event-fanout";
23
25
  import {
24
26
  effectiveSandboxLifecycle,
25
27
  sandboxArchiveCaptureTimeoutMs,
@@ -171,6 +173,8 @@ export type SandboxResumeServices = {
171
173
  * call. Absent, the call gets its own single replacement.
172
174
  */
173
175
  freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
176
+ /** Live fanout for background commands a provider loss settled. */
177
+ bus?: EventBus | null;
174
178
  /** Called only by the observer that wins the exact warm->cold loss CAS. */
175
179
  onSandboxLost?: (input: {
176
180
  sandboxGroupId: string;
@@ -2155,6 +2159,11 @@ async function resumeBoxForTurnOnce(
2155
2159
  expectedBackend: ids.backend,
2156
2160
  });
2157
2161
  if (marked.status === "marked") {
2162
+ await publishDurableSessionEvents(
2163
+ services.bus,
2164
+ ids.workspaceId,
2165
+ marked.backgroundCommandEvents,
2166
+ );
2158
2167
  await services.onSandboxLost?.({
2159
2168
  sandboxGroupId: ids.sandboxGroupId,
2160
2169
  instanceId: live.instanceId,