@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
@@ -1,4 +1,5 @@
1
1
  import { SandboxCapabilitiesChangedError } from "./provider-dispatch-barrier";
2
+ import { ClaudeSubscriptionConnectionUnavailable } from "./claude-usage-observer";
2
3
  import {
3
4
  ActiveSessionHistoryLimitExceededError,
4
5
  ApprovalRunStateLimitExceededError,
@@ -12,6 +13,7 @@ import {
12
13
  } from "@opengeni/db";
13
14
  import {
14
15
  ActiveBackendUnresolvableError,
16
+ AnthropicProviderRejection,
15
17
  CompactionProviderResponseError,
16
18
  EmptyCompactionSummaryError,
17
19
  compactionProviderRejection,
@@ -21,6 +23,7 @@ import {
21
23
  isMcpTransportConnectivityError,
22
24
  isModalTaskExecStartPreDispatchUnavailableError,
23
25
  isModalCommandStartOutcomeUnknownError,
26
+ isProviderCommandObservationUnavailableError,
24
27
  isRoutingMutationOutcomeUnknownError,
25
28
  RoutingWorkspaceRootChangedError,
26
29
  ResponsesStreamingTerminalError,
@@ -34,6 +37,7 @@ import {
34
37
  providerQuotaExhaustedMessage,
35
38
  SelfhostedWorkspaceRootChangedError,
36
39
  UNKNOWN_MODEL_FINISH_REASON_CODE,
40
+ AnthropicRequestError,
37
41
  } from "@opengeni/runtime";
38
42
  import {
39
43
  mcpTransportRequestFailureDiagnostic,
@@ -396,6 +400,7 @@ export function postClaimDatabaseRecoveryFailure(input: {
396
400
  triggerEventId: string;
397
401
  executionGeneration: number;
398
402
  sandboxSetupOutcomeUnknown?: true;
403
+ sandboxSetupRecoveryExhausted?: true;
399
404
  providerRecovery?: {
400
405
  failureCode: string;
401
406
  providerRecoveryCount: number;
@@ -403,7 +408,13 @@ export function postClaimDatabaseRecoveryFailure(input: {
403
408
  }): ApplicationFailure | null {
404
409
  const code = retryableDatabaseFailureCode(input.error);
405
410
  if (!code || input.executionGeneration < 1) return null;
406
- if (input.sandboxSetupOutcomeUnknown && input.providerRecovery) return null;
411
+ if (
412
+ (input.sandboxSetupOutcomeUnknown && input.sandboxSetupRecoveryExhausted) ||
413
+ ((input.sandboxSetupOutcomeUnknown || input.sandboxSetupRecoveryExhausted) &&
414
+ input.providerRecovery)
415
+ ) {
416
+ return null;
417
+ }
407
418
  if (
408
419
  input.providerRecovery &&
409
420
  (!Number.isSafeInteger(input.providerRecovery.providerRecoveryCount) ||
@@ -419,6 +430,7 @@ export function postClaimDatabaseRecoveryFailure(input: {
419
430
  executionGeneration: input.executionGeneration,
420
431
  code,
421
432
  ...(input.sandboxSetupOutcomeUnknown ? { sandboxSetupOutcomeUnknown: true } : {}),
433
+ ...(input.sandboxSetupRecoveryExhausted ? { sandboxSetupRecoveryExhausted: true } : {}),
422
434
  ...(input.providerRecovery
423
435
  ? {
424
436
  providerFailureCode: input.providerRecovery.failureCode,
@@ -1032,15 +1044,44 @@ function isRawDatabaseQueryError(error: unknown): boolean {
1032
1044
  );
1033
1045
  }
1034
1046
 
1047
+ function anthropicRequestDiagnostic(error: unknown): AnthropicRequestError | undefined {
1048
+ if (error instanceof AnthropicRequestError) return error;
1049
+ return error instanceof Error && error.cause instanceof AnthropicRequestError
1050
+ ? error.cause
1051
+ : undefined;
1052
+ }
1053
+
1035
1054
  export function agentRunFailurePayload(
1036
1055
  error: unknown,
1037
1056
  options: { isCodexTurn?: boolean } = {},
1038
1057
  ): ReturnType<typeof baseAgentRunFailurePayload> {
1039
1058
  const failure = baseAgentRunFailurePayload(error, options);
1040
1059
  const diagnostic = materializationVerificationDiagnostic(error);
1060
+ const anthropic = anthropicRequestDiagnostic(error);
1061
+ if (anthropic) {
1062
+ return {
1063
+ ...failure,
1064
+ code: failure.code ?? anthropic.code,
1065
+ retryable: failure.retryable ?? false,
1066
+ ...(anthropic.detail ? { detail: anthropic.detail } : {}),
1067
+ ...(anthropic.request_id ? { requestId: anthropic.request_id } : {}),
1068
+ };
1069
+ }
1041
1070
  return diagnostic ? { ...failure, materializationDiagnostic: diagnostic } : failure;
1042
1071
  }
1043
1072
 
1073
+ /** Keep Anthropic provider text on terminal failures, never recovery events. */
1074
+ export function agentRunRecoveryFailurePayload(
1075
+ error: unknown,
1076
+ failure: ReturnType<typeof agentRunFailurePayload>,
1077
+ ): ReturnType<typeof agentRunFailurePayload> {
1078
+ if (!anthropicRequestDiagnostic(error)) return failure;
1079
+ // Project a copy: retry exhaustion still needs the terminal diagnostic.
1080
+ const recovery = { ...failure };
1081
+ delete recovery.detail;
1082
+ return recovery;
1083
+ }
1084
+
1044
1085
  function baseAgentRunFailurePayload(
1045
1086
  error: unknown,
1046
1087
  options: { isCodexTurn?: boolean } = {},
@@ -1111,6 +1152,17 @@ function baseAgentRunFailurePayload(
1111
1152
  ...(Object.keys(database).length > 0 ? { database } : {}),
1112
1153
  };
1113
1154
  }
1155
+ if (error instanceof ClaudeSubscriptionConnectionUnavailable) {
1156
+ return { error: error.message, code: error.code, retryable: false };
1157
+ }
1158
+ if (error instanceof AnthropicProviderRejection) {
1159
+ return {
1160
+ error: error.message,
1161
+ code: error.code === "content_policy_violation" ? "provider_safety_refusal" : error.code,
1162
+ retryable: false,
1163
+ ...(error.request_id ? { requestId: error.request_id } : {}),
1164
+ };
1165
+ }
1114
1166
  const safetyRefusalDiagnostic = providerSafetyRefusalDiagnostic(error);
1115
1167
  if (safetyRefusalDiagnostic !== undefined) {
1116
1168
  return {
@@ -1194,6 +1246,14 @@ function baseAgentRunFailurePayload(
1194
1246
  retryable: true,
1195
1247
  };
1196
1248
  }
1249
+ if (isProviderCommandObservationUnavailableError(error)) {
1250
+ return {
1251
+ error:
1252
+ "A managed sandbox command cannot be observed. Its exact invocation and writer remain retained; setup is blocked without replay until the incomplete operation can be reconciled.",
1253
+ code: "sandbox_command_observation_unavailable",
1254
+ retryable: false,
1255
+ };
1256
+ }
1197
1257
  if (isModalCommandStartOutcomeUnknownError(error)) {
1198
1258
  return {
1199
1259
  error:
@@ -13,6 +13,7 @@ import {
13
13
  readLease,
14
14
  SandboxLeaseSupersededError,
15
15
  isSessionEventPersistenceError,
16
+ SANDBOX_SETUP_RECOVERY_LIMIT,
16
17
  type CodexLeaseAccountStatus,
17
18
  } from "@opengeni/db";
18
19
  import { publishDurableSessionEvents } from "@opengeni/events";
@@ -20,6 +21,7 @@ import {
20
21
  maxTurnsExceededRunState,
21
22
  isModalTaskExecStartPreDispatchUnavailableError,
22
23
  isModalCommandStartOutcomeUnknownError,
24
+ isProviderCommandObservationUnavailableError,
23
25
  } from "@opengeni/runtime";
24
26
  import { ApplicationFailure, CancelledFailure } from "@temporalio/activity";
25
27
  import {
@@ -68,6 +70,7 @@ import {
68
70
  safeErrorDiagnostic,
69
71
  classifyXaiCredentialFailure,
70
72
  agentRunFailurePayload,
73
+ agentRunRecoveryFailurePayload,
71
74
  codexCredentialCooldownUntil,
72
75
  classifyCodexCredentialFailure,
73
76
  codexUsageLimitFailurePayload,
@@ -343,8 +346,9 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
343
346
  // cannot reconstruct setup on a replacement attempt. The exact command and
344
347
  // writer remain retained by sandbox-runtime; the logical turn is parked as
345
348
  // recovering with a durable no-replay marker, not failed or completed.
349
+ const observationUnavailable = isProviderCommandObservationUnavailableError(error);
346
350
  if (
347
- isModalCommandStartOutcomeUnknownError(error) &&
351
+ (isModalCommandStartOutcomeUnknownError(error) || observationUnavailable) &&
348
352
  recoveryTurnId &&
349
353
  attempt.triggerEventId &&
350
354
  attempt.executionGeneration > 0
@@ -360,10 +364,14 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
360
364
  turnId: recoveryTurnId,
361
365
  triggerEventId: attempt.triggerEventId,
362
366
  attemptId: input.attemptId,
363
- reason: "sandbox_command_start_outcome_unknown",
367
+ reason: observationUnavailable
368
+ ? "sandbox_command_observation_unavailable"
369
+ : "sandbox_command_start_outcome_unknown",
364
370
  sandboxSetupOutcomeUnknown: true,
365
371
  detail: {
366
- code: "sandbox_command_start_outcome_unknown",
372
+ code: observationUnavailable
373
+ ? "sandbox_command_observation_unavailable"
374
+ : "sandbox_command_start_outcome_unknown",
367
375
  retryable: false,
368
376
  setupOutcome: "unknown",
369
377
  replay: "blocked",
@@ -1749,7 +1757,45 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1749
1757
  attemptNumber: nextProviderRecoveryCount,
1750
1758
  retryAfterMs: providerRetryAfterMs(error),
1751
1759
  });
1760
+ const setupRecoveryExhausted =
1761
+ earlyCommandStartUnavailable &&
1762
+ recoveryResult.status === "exhausted" &&
1763
+ attempt.providerRecoveryCount === SANDBOX_SETUP_RECOVERY_LIMIT;
1752
1764
  try {
1765
+ if (setupRecoveryExhausted) {
1766
+ // This is positive pre-dispatch proof, not an ambiguous command. Park
1767
+ // the SAME accepted turn without resetting or advancing its budget.
1768
+ const recovery = await requestSessionTurnRecovery(db, input.workspaceId, {
1769
+ sessionId: input.sessionId,
1770
+ turnId: attempt.turnId,
1771
+ triggerEventId: attempt.triggerEventId!,
1772
+ attemptId: input.attemptId,
1773
+ reason: "sandbox_command_start_recovery_exhausted",
1774
+ sandboxSetupRecoveryExhausted: true,
1775
+ detail: {
1776
+ code: failure.code,
1777
+ error:
1778
+ "Automatic sandbox setup recovery exhausted after five retries; the accepted turn remains parked without starting a command.",
1779
+ retryable: false,
1780
+ setupOutcome: "not_started",
1781
+ replay: "blocked",
1782
+ recoveryExhausted: true,
1783
+ providerRecoveryCount: SANDBOX_SETUP_RECOVERY_LIMIT,
1784
+ },
1785
+ });
1786
+ if (recovery.action === "stale") {
1787
+ acknowledgeLostAttemptOwnership();
1788
+ control.activityStatus = "cancelled";
1789
+ control.turnMetricOutcome = "cancelled";
1790
+ return claimedResult({ status: "cancelled" });
1791
+ }
1792
+ acknowledgeRecoveryQuiescence();
1793
+ await publishDurableSessionEvents(bus, input.workspaceId, input.sessionId, recovery.events);
1794
+ control.turnMetricOutcome = "recovering";
1795
+ control.activityStatus = "recovering";
1796
+ control.activityError = error;
1797
+ return claimedResult({ status: "recovering" });
1798
+ }
1753
1799
  if (recoveryResult.status === "recovering") {
1754
1800
  if (!earlyRecoverableSetup) {
1755
1801
  await flushRuntimeBatcher();
@@ -1763,7 +1809,7 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1763
1809
  reason: failure.code ?? "provider_unavailable",
1764
1810
  providerRecoveryCount: nextProviderRecoveryCount,
1765
1811
  detail: {
1766
- ...failure,
1812
+ ...agentRunRecoveryFailurePayload(error, failure),
1767
1813
  continueDelayMs: recoveryResult.continueDelayMs,
1768
1814
  providerRecoveryCount: nextProviderRecoveryCount,
1769
1815
  },
@@ -1831,7 +1877,15 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1831
1877
  providerRecoveryCount: nextProviderRecoveryCount,
1832
1878
  },
1833
1879
  })
1834
- : null;
1880
+ : setupRecoveryExhausted
1881
+ ? postClaimDatabaseRecoveryFailure({
1882
+ error: recoveryError,
1883
+ turnId: attempt.turnId,
1884
+ triggerEventId: attempt.triggerEventId!,
1885
+ executionGeneration: attempt.executionGeneration,
1886
+ sandboxSetupRecoveryExhausted: true,
1887
+ })
1888
+ : null;
1835
1889
  if (postClaimRecovery) {
1836
1890
  control.activityStatus = "recovering";
1837
1891
  control.turnMetricOutcome = "recovering";
@@ -41,7 +41,7 @@ export function startTurnFinalizationMonitor(input: {
41
41
  observability: Observability;
42
42
  details: TurnHeartbeatDetails;
43
43
  heartbeat: (details: TurnHeartbeatDetails) => void;
44
- terminateWorker: () => void;
44
+ requestWorkerDrain: () => void;
45
45
  timeoutMs?: number;
46
46
  slowAfterMs?: number;
47
47
  }) {
@@ -93,10 +93,9 @@ export function startTurnFinalizationMonitor(input: {
93
93
  // Diagnostic failure must never disable physical containment.
94
94
  }
95
95
  },
96
- terminateWorker: input.terminateWorker,
96
+ terminateWorker: input.requestWorkerDrain,
97
97
  });
98
- // Record the precursor while the process remains scrapeable. A counter
99
- // increment immediately before process.exit can never reach Prometheus.
98
+ // Record the precursor while the process remains scrapeable.
100
99
  slowTimer = setTimeout(
101
100
  () =>
102
101
  diagnose(() => {
@@ -74,6 +74,8 @@ export type TurnFinalizationDeps = {
74
74
  wakeSessionWorkflow: ActivityServices["wakeSessionWorkflow"];
75
75
  signalSessionAttemptQuiesced: ActivityServices["signalSessionAttemptQuiesced"];
76
76
  signalCodexCapacityWorkflow: ActivityServices["signalCodexCapacityWorkflow"];
77
+ requestWorkerDrain: ActivityServices["requestWorkerDrain"];
78
+ turnFinalizationTimeoutMs: ActivityServices["turnFinalizationTimeoutMs"];
77
79
  cancellationSignal: AbortSignal | undefined;
78
80
  sandboxResumeController: AbortController;
79
81
  activityContext: ReturnType<typeof currentActivityContext>;
@@ -113,6 +115,9 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
113
115
  deps.eventing.heartbeatTimer = startActivityHeartbeat(deps.activityContext, details);
114
116
  const monitor = startTurnFinalizationMonitor({
115
117
  observability: deps.observability,
118
+ ...(deps.turnFinalizationTimeoutMs === undefined
119
+ ? {}
120
+ : { timeoutMs: deps.turnFinalizationTimeoutMs }),
116
121
  details,
117
122
  heartbeat: (value) => {
118
123
  try {
@@ -121,7 +126,7 @@ export async function finalizeTurnAttempt(deps: TurnFinalizationDeps): Promise<v
121
126
  // A closed Temporal transport is not proof of physical quiescence.
122
127
  }
123
128
  },
124
- terminateWorker: () => process.exit(1),
129
+ requestWorkerDrain: deps.requestWorkerDrain,
125
130
  });
126
131
  try {
127
132
  monitor.enter("tool_writers");
@@ -411,12 +416,12 @@ async function finalizeTurnAttemptSteps(
411
416
  // best-effort (same discipline as today's usage write). Both writers skip
412
417
  // version/updatedAt, so neither can race the token-refresh CAS.
413
418
  monitor.enter("provider_leases");
414
- for (const [scope, snapshot] of providerTurn.latestClaudeUsage) {
419
+ for (const snapshot of providerTurn.latestClaudeUsage.values()) {
415
420
  await waitForTurnFinalizerStep(
416
421
  recordClaudeSubscriptionUsage(
417
422
  db,
418
423
  settings,
419
- { accountId: input.accountId, workspaceId: input.workspaceId, scope },
424
+ { accountId: input.accountId, workspaceId: input.workspaceId, scope: snapshot.scope },
420
425
  snapshot,
421
426
  ).catch(() => null),
422
427
  finalizerSignal,
@@ -91,10 +91,9 @@ export const QUIESCENCE_PROOF_SIGNAL_MAX_RETRY_MS = 5_000;
91
91
  export const TURN_QUIESCENCE_WATCHDOG_MS = 5 * 60_000;
92
92
  export const TURN_FINALIZER_STEP_TIMEOUT_MS = 30_000;
93
93
 
94
- /** A fenced activity must never heartbeat forever while its physical writer
95
- * drain is stuck. The last-resort worker exit stops every in-process writer;
96
- * Kubernetes replaces the pod and the workflow's heartbeat-lease reconciler
97
- * then commits the exact quiescence receipt. */
94
+ /** A fenced activity must never heartbeat forever while physical cleanup is
95
+ * stuck. The callback requests host-owned containment, which drains peer turns
96
+ * before applying its final exit policy. A timeout is never quiescence proof. */
98
97
  export function armTurnQuiescenceWatchdog(input: {
99
98
  enabled: boolean;
100
99
  timeoutMs?: number;
@@ -183,6 +183,8 @@ export function createRunAgentTurnActivity(services: () => Promise<ActivityServi
183
183
  bus,
184
184
  runtime,
185
185
  summarizeContextForCompaction,
186
+ requestWorkerDrain,
187
+ turnFinalizationTimeoutMs,
186
188
  objectStorage,
187
189
  observability,
188
190
  wakeSessionWorkflow,
@@ -944,28 +946,28 @@ export function createRunAgentTurnActivity(services: () => Promise<ActivityServi
944
946
  );
945
947
  const withClaudeUsage = <T>(fn: () => Promise<T>): Promise<T> =>
946
948
  withClaudeUsageObserver(claudeUsageObserver, fn, async (providerId, headers) => {
947
- const binding = claudeUsageObserver.binding(providerId);
948
- if (!binding) return headers;
949
- const credential = await resolveClaudeSubscriptionCredential(
950
- db,
951
- settings,
952
- {
953
- accountId: input.accountId,
954
- workspaceId: input.workspaceId,
955
- scope: binding.scope,
956
- },
957
- {
958
- expectedConnectionId: binding.expectedConnectionId,
959
- expectedCredentialVersion: binding.expectedCredentialVersion,
949
+ return claudeUsageObserver.prepareRequestWithObserver(
950
+ providerId,
951
+ headers,
952
+ async (binding) => {
953
+ const credential = await resolveClaudeSubscriptionCredential(
954
+ db,
955
+ settings,
956
+ {
957
+ accountId: input.accountId,
958
+ workspaceId: input.workspaceId,
959
+ scope: binding.scope,
960
+ },
961
+ {
962
+ expectedConnectionId: binding.expectedConnectionId,
963
+ expectedCredentialVersion: binding.expectedCredentialVersion,
964
+ },
965
+ );
966
+ if (credential && "reconnectRequired" in credential)
967
+ throw new ClaudeSubscriptionReconnectRequired();
968
+ return credential;
960
969
  },
961
970
  );
962
- if (!credential) return headers; // A replaced connection never lends its new token to this turn.
963
- if ("reconnectRequired" in credential)
964
- throw new ClaudeSubscriptionReconnectRequired();
965
- claudeUsageObserver.renew(providerId, credential);
966
- headers.set("authorization", `Bearer ${credential.token}`);
967
- headers.delete("x-api-key");
968
- return headers;
969
971
  });
970
972
  const withCodex = <T>(fn: () => Promise<T>): Promise<T> =>
971
973
  codexContext ? codexRequestStorage.run(codexContext, fn) : fn();
@@ -1844,6 +1846,8 @@ export function createRunAgentTurnActivity(services: () => Promise<ActivityServi
1844
1846
  });
1845
1847
  } finally {
1846
1848
  await finalizeTurnAttempt({
1849
+ turnFinalizationTimeoutMs,
1850
+ requestWorkerDrain,
1847
1851
  input,
1848
1852
  settings,
1849
1853
  db,
@@ -656,6 +656,7 @@ export async function establishTurnSandbox(deps: EstablishTurnSandboxDeps): Prom
656
656
  freshSandboxReadinessReplacementBudget,
657
657
  onSandboxLost: publishSandboxLost,
658
658
  objectStorage,
659
+ bus,
659
660
  },
660
661
  {
661
662
  accountId: input.accountId,
@@ -735,6 +736,7 @@ export async function establishTurnSandbox(deps: EstablishTurnSandboxDeps): Prom
735
736
  freshSandboxReadinessReplacementBudget,
736
737
  onSandboxLost: publishSandboxLost,
737
738
  objectStorage,
739
+ bus,
738
740
  },
739
741
  {
740
742
  accountId: input.accountId,
@@ -13,6 +13,7 @@ import { createProviderCommandRetainer } from "@opengeni/db/retained-provider-co
13
13
  import {
14
14
  RoutingMutationOutcomeUnknownError,
15
15
  ProviderCommandStartOutcomeUnknownError,
16
+ ProviderCommandObservationUnavailableError,
16
17
  isModalCommandStartOutcomeUnknownError,
17
18
  withModalCommandStartSignal,
18
19
  resolveModalCheckpointProviderBindingForSession,
@@ -84,7 +85,10 @@ function internalMutationUnknownCommand(
84
85
  if (!value || typeof value !== "object" || seen.has(value)) continue;
85
86
  seen.add(value);
86
87
  try {
87
- if (value instanceof ProviderCommandStartOutcomeUnknownError) {
88
+ if (
89
+ value instanceof ProviderCommandStartOutcomeUnknownError ||
90
+ value instanceof ProviderCommandObservationUnavailableError
91
+ ) {
88
92
  unknown = true;
89
93
  // Zod reads ordinary properties. First make a bounded own-data-only
90
94
  // copy so neither validation nor equality executes descriptor getters.
@@ -20,6 +20,7 @@ export function configuredBundledSkillNames(context: BundledSkillConfiguration):
20
20
  { name: "document-parsing", include: () => true },
21
21
  { name: "opengeni-skills", include: () => true },
22
22
  { name: "opengeni-projects", include: () => true },
23
+ { name: "opengeni-schedules", include: () => tools.has("scheduled_tasks_create") },
23
24
  { name: "opengeni-documents", include: artifacts },
24
25
  { name: "opengeni-spreadsheets", include: artifacts },
25
26
  { name: "opengeni-presentations", include: artifacts },
@@ -43,6 +44,7 @@ export function loadConfiguredBundledSkills(context: BundledSkillConfiguration)
43
44
  const names = new Set(configuredBundledSkillNames(context));
44
45
  const artifacts = loadNativeToolSkillArtifacts({
45
46
  projects: names.has("opengeni-projects"),
47
+ schedules: names.has("opengeni-schedules"),
46
48
  editableArtifacts: [
47
49
  "opengeni-documents",
48
50
  "opengeni-spreadsheets",
@@ -0,0 +1,115 @@
1
+ export type LeaseRenewReason = "timer" | "runtime_event" | "model_usage";
2
+ export type LeaseLossReason = "deadline" | "not_found";
3
+
4
+ type LeaseIdentity = { turnId: string; holderId: string; generation: number };
5
+ export type SubscriptionLeaseDeps = {
6
+ ttlMs: number;
7
+ getTurnId: () => string | undefined;
8
+ heartbeat: (identity: LeaseIdentity) => Promise<Date | null>;
9
+ onLost: (reason: LeaseLossReason) => void;
10
+ onRenewed?: (reason: LeaseRenewReason) => void;
11
+ onError: (error: unknown, reason: LeaseRenewReason) => void;
12
+ lostError: (reason: LeaseLossReason) => Error;
13
+ now?: () => number;
14
+ };
15
+
16
+ /** Cross-provider lease lifecycle. Only on-time DB confirmation extends ownership. */
17
+ export class SubscriptionTurnLease {
18
+ held = false;
19
+ lost = false;
20
+ lossReason: LeaseLossReason | null = null;
21
+ holderId: string | null = null;
22
+ generation: number | null = null;
23
+ confirmedUntilMs: number | null = null;
24
+ private heartbeatInFlight = false;
25
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
26
+ private readonly now: () => number;
27
+
28
+ constructor(private readonly leaseDeps: SubscriptionLeaseDeps) {
29
+ if (!Number.isFinite(leaseDeps.ttlMs) || leaseDeps.ttlMs <= 0)
30
+ throw new Error("Subscription lease TTL must be positive");
31
+ this.now = leaseDeps.now ?? (() => performance.now());
32
+ }
33
+
34
+ private expired = (deadline: number | null): boolean =>
35
+ deadline === null || !Number.isFinite(deadline) || this.now() >= deadline;
36
+
37
+ markLost = (reason: LeaseLossReason): void => {
38
+ if (this.lost) return;
39
+ this.lost = true;
40
+ this.lossReason = reason;
41
+ this.leaseDeps.onLost(reason);
42
+ };
43
+
44
+ assertUsable = (): void => {
45
+ if (this.lost) throw this.leaseDeps.lostError(this.lossReason ?? "not_found");
46
+ if (
47
+ !this.held ||
48
+ this.holderId === null ||
49
+ this.generation === null ||
50
+ this.expired(this.confirmedUntilMs)
51
+ ) {
52
+ this.markLost("deadline");
53
+ throw this.leaseDeps.lostError("deadline");
54
+ }
55
+ };
56
+
57
+ renew = async (reason: LeaseRenewReason = "timer"): Promise<void> => {
58
+ const turnId = this.leaseDeps.getTurnId();
59
+ if (!turnId || !this.held || !this.holderId || this.generation === null || this.lost) return;
60
+ if (this.expired(this.confirmedUntilMs)) {
61
+ this.markLost("deadline");
62
+ return;
63
+ }
64
+ if (this.heartbeatInFlight) return;
65
+ this.heartbeatInFlight = true;
66
+ const priorDeadline = this.confirmedUntilMs;
67
+ const startedAt = this.now();
68
+ const identity = { turnId, holderId: this.holderId, generation: this.generation };
69
+ try {
70
+ const renewedUntil = await this.leaseDeps.heartbeat(identity);
71
+ // A late confirmation is not proof of uninterrupted ownership, even if
72
+ // Postgres extended the row. A changed holder must also discard its reply.
73
+ if (
74
+ !this.held ||
75
+ this.lost ||
76
+ this.leaseDeps.getTurnId() !== identity.turnId ||
77
+ this.holderId !== identity.holderId ||
78
+ this.generation !== identity.generation
79
+ )
80
+ return;
81
+ if (!renewedUntil) this.markLost("not_found");
82
+ else if (this.expired(priorDeadline) || this.expired(startedAt + this.leaseDeps.ttlMs))
83
+ this.markLost("deadline");
84
+ else {
85
+ this.confirmedUntilMs = startedAt + this.leaseDeps.ttlMs;
86
+ this.leaseDeps.onRenewed?.(reason);
87
+ }
88
+ } catch (error) {
89
+ if (
90
+ !this.held ||
91
+ this.lost ||
92
+ this.leaseDeps.getTurnId() !== identity.turnId ||
93
+ this.holderId !== identity.holderId ||
94
+ this.generation !== identity.generation
95
+ )
96
+ return;
97
+ if (this.expired(this.confirmedUntilMs)) this.markLost("deadline");
98
+ else this.leaseDeps.onError(error, reason);
99
+ } finally {
100
+ this.heartbeatInFlight = false;
101
+ }
102
+ };
103
+
104
+ startHeartbeat = (): void => {
105
+ if (!this.leaseDeps.getTurnId() || this.heartbeatTimer) return;
106
+ this.heartbeatTimer = setInterval(() => void this.renew("timer"), 60_000);
107
+ this.heartbeatTimer.unref?.();
108
+ };
109
+
110
+ stopHeartbeat = (): void => {
111
+ if (!this.heartbeatTimer) return;
112
+ clearInterval(this.heartbeatTimer);
113
+ this.heartbeatTimer = undefined;
114
+ };
115
+ }
@@ -191,10 +191,7 @@ export type ProviderTurnState = {
191
191
  // scraped. Lives on the turn context so the finalizer sees it; the sink is
192
192
  // wired into codexContext.onUsageHeaders by the orchestrator.
193
193
  latestCodexUsage: CodexUsageHeaderSnapshot | null;
194
- latestClaudeUsage: Map<
195
- "workspace" | "organization",
196
- import("./claude-usage-observer").CapturedClaudeUsage
197
- >;
194
+ latestClaudeUsage: Map<string, import("./claude-usage-observer").CapturedClaudeUsage>;
198
195
  lastCodexRequestOpaqueArtifacts: readonly string[];
199
196
  };
200
197
 
@@ -1,3 +1,4 @@
1
+ import { subscriptionAccountShardIndex } from "@opengeni/config";
1
2
  // Multi-account P3 — the PURE rotation ranker. Zero I/O: no provider calls, no
2
3
  // decrypts, no db. It consumes the already-loaded, metadata-only account list
3
4
  // (cached usage columns + the exhausted_until cooldown column) and returns the
@@ -223,17 +224,6 @@ export function isCodexAccountEligible(acct: CodexRotationAccount, now: Date): b
223
224
  return isCodexCredentialEligible(acct, now);
224
225
  }
225
226
 
226
- /** Deterministic 32-bit FNV-1a over a UTF-16 code-unit stream. Pure, allocation-free. */
227
- function fnv1a32(input: string): number {
228
- let hash = 0x811c9dc5;
229
- for (let i = 0; i < input.length; i++) {
230
- hash ^= input.charCodeAt(i);
231
- // 32-bit FNV prime multiply via Math.imul; `>>> 0` keeps it unsigned.
232
- hash = Math.imul(hash, 0x01000193);
233
- }
234
- return hash >>> 0;
235
- }
236
-
237
227
  /**
238
228
  * Session-sharded HOME account (AM-6): the deterministic account a session runs on
239
229
  * under the "sharded" strategy — `stableAccountList[ hash(sessionId) % N ]` over the
@@ -263,7 +253,7 @@ export function shardCredentialForSession(args: {
263
253
  if (eligibles.length === 0) {
264
254
  return null;
265
255
  }
266
- const index = fnv1a32(sessionId) % eligibles.length;
256
+ const index = subscriptionAccountShardIndex(sessionId, eligibles.length);
267
257
  return eligibles[index]!.id;
268
258
  }
269
259