@opengeni/worker-bundle 2.0.1 → 2.0.3

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 (28) hide show
  1. package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
  2. package/dist/activities/agent-turn/history.d.ts +2 -0
  3. package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
  4. package/dist/activities/retained-screenshots.d.ts +7 -2
  5. package/dist/activities/sandbox-lease.d.ts +3 -4
  6. package/dist/{activities-control-QA6OLRQY.js → activities-control-IVTL723D.js} +204 -23
  7. package/dist/activities-control-IVTL723D.js.map +1 -0
  8. package/dist/{activities-turn-YVVKCGAK.js → activities-turn-EZEDZTEZ.js} +108 -33
  9. package/dist/activities-turn-EZEDZTEZ.js.map +1 -0
  10. package/dist/{chunk-M3NVMF2V.js → chunk-JMO5ZUW3.js} +14 -7
  11. package/dist/{chunk-M3NVMF2V.js.map → chunk-JMO5ZUW3.js.map} +1 -1
  12. package/dist/index.js +7 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/workflow-bundle.js +4 -1
  15. package/package.json +15 -15
  16. package/src/activities/agent-turn/errors.ts +8 -3
  17. package/src/activities/agent-turn/failure-settlement.ts +40 -8
  18. package/src/activities/agent-turn/history.ts +14 -1
  19. package/src/activities/agent-turn/media-artifacts.ts +2 -1
  20. package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
  21. package/src/activities/agent-turn/stream-attempt.ts +13 -2
  22. package/src/activities/knowledge-indexing.ts +133 -6
  23. package/src/activities/retained-screenshots.ts +82 -11
  24. package/src/activities/sandbox-lease.ts +141 -21
  25. package/src/sandbox-resume.ts +6 -0
  26. package/src/workflows/activities.ts +14 -1
  27. package/dist/activities-control-QA6OLRQY.js.map +0 -1
  28. package/dist/activities-turn-YVVKCGAK.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/worker-bundle",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "OpenGeni worker entry and reusable embedded lifecycle, shipped with a release-coherent pre-bundled Temporal workflow artifact.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -56,21 +56,21 @@
56
56
  "@llamaindex/liteparse": "2.14.2",
57
57
  "@opengeni/agent-proto": "^0.6.0",
58
58
  "@opengeni/capabilities": "^0.3.4",
59
- "@opengeni/codemode": "^0.6.1",
60
- "@opengeni/codex": "^0.2.25",
61
- "@opengeni/config": "^2.1.0",
62
- "@opengeni/contracts": "^5.1.0",
63
- "@opengeni/core": "^4.0.1",
64
- "@opengeni/db": "^6.0.1",
65
- "@opengeni/documents": "^0.8.32",
66
- "@opengeni/events": "^0.4.30",
67
- "@opengeni/github": "^0.7.15",
59
+ "@opengeni/codemode": "^0.6.2",
60
+ "@opengeni/codex": "^0.2.26",
61
+ "@opengeni/config": "^2.1.1",
62
+ "@opengeni/contracts": "^5.1.1",
63
+ "@opengeni/core": "^4.0.3",
64
+ "@opengeni/db": "^6.0.3",
65
+ "@opengeni/documents": "^0.8.34",
66
+ "@opengeni/events": "^0.4.32",
67
+ "@opengeni/github": "^0.7.16",
68
68
  "@opengeni/network": "^0.3.1",
69
- "@opengeni/observability": "^0.8.31",
70
- "@opengeni/runtime": "^4.0.1",
71
- "@opengeni/sdk": "^7.1.0",
72
- "@opengeni/storage": "^0.2.132",
73
- "@opengeni/tool-gateway": "^0.1.12",
69
+ "@opengeni/observability": "^0.8.32",
70
+ "@opengeni/runtime": "^4.0.3",
71
+ "@opengeni/sdk": "^7.1.1",
72
+ "@opengeni/storage": "^0.2.133",
73
+ "@opengeni/tool-gateway": "^0.1.13",
74
74
  "@opengeni/xai-subscription": "^0.1.4",
75
75
  "@temporalio/activity": "^1.17.0",
76
76
  "@temporalio/client": "^1.17.0",
@@ -16,7 +16,8 @@ import {
16
16
  type CompactionProviderRejection,
17
17
  isMcpRequestTimeoutError,
18
18
  isMcpTransportConnectivityError,
19
- isModalTaskExecStartDnsResolutionError,
19
+ isModalTaskExecStartPreDispatchUnavailableError,
20
+ isRoutingMutationOutcomeUnknownError,
20
21
  RoutingWorkspaceRootChangedError,
21
22
  SandboxMaterializationVerificationError,
22
23
  materializationVerificationDiagnostic,
@@ -119,6 +120,7 @@ export function providerRecoveryResult(input: {
119
120
  input.failureCode === "sandbox_command_start_unavailable" ||
120
121
  input.failureCode === "mcp_transport_timeout" ||
121
122
  input.failureCode === "mcp_transport_unavailable" ||
123
+ input.failureCode === "turn_execution_policy_definition_mismatch" ||
122
124
  input.failureCode === POST_COMPACTION_CONTINUATION_EMPTY_CODE
123
125
  ? Math.max(
124
126
  providerDelay ?? 0,
@@ -1021,10 +1023,13 @@ function baseAgentRunFailurePayload(
1021
1023
  retryable: true,
1022
1024
  };
1023
1025
  }
1024
- if (isModalTaskExecStartDnsResolutionError(error)) {
1026
+ if (
1027
+ !isRoutingMutationOutcomeUnknownError(error) &&
1028
+ isModalTaskExecStartPreDispatchUnavailableError(error)
1029
+ ) {
1025
1030
  return {
1026
1031
  error:
1027
- "The managed sandbox command transport was temporarily unreachable before the command started. The same turn will retry after a short delay.",
1032
+ "The managed sandbox command router was not ready before the command was sent. The same turn will retry after a short delay.",
1028
1033
  code: "sandbox_command_start_unavailable",
1029
1034
  retryable: true,
1030
1035
  };
@@ -16,14 +16,14 @@ import {
16
16
  } from "@opengeni/db";
17
17
  import { publishDurableSessionEvents } from "@opengeni/events";
18
18
  import { maxTurnsExceededRunState } from "@opengeni/runtime";
19
- import { CancelledFailure } from "@temporalio/activity";
19
+ import { ApplicationFailure, CancelledFailure } from "@temporalio/activity";
20
20
  import {
21
21
  authoritativeCodexCapacityResetAt,
22
22
  classifyCodexPin,
23
23
  selectCodexCredentialLeaseForTurn,
24
24
  type CodexRotationStrategy,
25
25
  } from "../codex-rotation";
26
- import type { Settings } from "@opengeni/config";
26
+ import { TurnExecutionPolicyDefinitionMismatchError, type Settings } from "@opengeni/config";
27
27
  import {
28
28
  classifyCodexEncryptedArtifactRejection,
29
29
  classifyCodexUsageLimitError,
@@ -1499,10 +1499,28 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1499
1499
  // truth, recover this SAME accepted turn, then let the workflow re-claim
1500
1500
  // it after a pacing delay. This is independent of goal state and never
1501
1501
  // relies on a synthetic continuation prompt.
1502
- let failure = agentRunFailurePayload(error, {
1503
- isCodexTurn: billingState.isCodexTurn,
1504
- }) as ReturnType<typeof agentRunFailurePayload>;
1505
- if (failure.retryable && eventing.publish && attempt.turnId && eventing.turnStartedPublished) {
1502
+ // A rolling-deployment definition mismatch is a separate configuration
1503
+ // class: only the exact typed setup error can use this checkpoint before
1504
+ // eventing exists. No generic setup/credential failure gains retry authority.
1505
+ const earlyDefinitionMismatch =
1506
+ error instanceof TurnExecutionPolicyDefinitionMismatchError &&
1507
+ !attempt.modelRequestStarted &&
1508
+ !eventing.turnStartedPublished &&
1509
+ !!attempt.turnId &&
1510
+ !!attempt.triggerEventId &&
1511
+ attempt.executionGeneration > 0;
1512
+ let failure = (
1513
+ earlyDefinitionMismatch
1514
+ ? { error: error.message, code: error.code, retryable: true }
1515
+ : agentRunFailurePayload(error, {
1516
+ isCodexTurn: billingState.isCodexTurn,
1517
+ })
1518
+ ) as ReturnType<typeof agentRunFailurePayload>;
1519
+ if (
1520
+ attempt.turnId &&
1521
+ (earlyDefinitionMismatch ||
1522
+ (failure.retryable && eventing.publish && eventing.turnStartedPublished))
1523
+ ) {
1506
1524
  const nextProviderRecoveryCount = attempt.providerRecoveryCount + 1;
1507
1525
  const recoveryResult = providerRecoveryResult({
1508
1526
  failureCode: failure.code,
@@ -1511,8 +1529,10 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1511
1529
  });
1512
1530
  try {
1513
1531
  if (recoveryResult.status === "recovering") {
1514
- await flushRuntimeBatcher();
1515
- await historySink.reconcileConversationTruth({ requireDurable: true });
1532
+ if (!earlyDefinitionMismatch) {
1533
+ await flushRuntimeBatcher();
1534
+ await historySink.reconcileConversationTruth({ requireDurable: true });
1535
+ }
1516
1536
  const recovery = await requestSessionTurnRecovery(db, input.workspaceId, {
1517
1537
  sessionId: input.sessionId,
1518
1538
  turnId: attempt.turnId,
@@ -1540,6 +1560,18 @@ export async function settleTurnFailure(deps: TurnFailureDeps): Promise<RunAgent
1540
1560
  return claimedResult(recoveryResult);
1541
1561
  }
1542
1562
  failure = providerRecoveryExhaustedFailure(failure, recoveryResult);
1563
+ if (earlyDefinitionMismatch) {
1564
+ // Setup has no eventing sink yet. Carry only the fixed, safe diagnostic
1565
+ // through Temporal into exact-attempt workflow failure settlement.
1566
+ control.activityStatus = "failed";
1567
+ control.turnMetricOutcome = "failed";
1568
+ control.activityError = error;
1569
+ throw ApplicationFailure.create({
1570
+ message: `${error.message}. Automatic same-turn configuration recovery exhausted after ${recoveryResult.providerRecoveryCount} retries.`,
1571
+ type: "TurnExecutionPolicyDefinitionMismatchError",
1572
+ nonRetryable: true,
1573
+ });
1574
+ }
1543
1575
  } catch (recoveryError) {
1544
1576
  const escaped =
1545
1577
  recoveryResult.status === "recovering"
@@ -186,7 +186,20 @@ export function toolCallProducesRetainableSessionImage(name: string | null): boo
186
186
  return (
187
187
  name === "computer_screenshot" ||
188
188
  name === "view_image" ||
189
- name === "interaction__computer_observe"
189
+ name === "interaction__computer_observe" ||
190
+ retainableBrowserScreenshotToolCall(name)
191
+ );
192
+ }
193
+
194
+ /** Browser interactions can carry an image block, including opt-in observe frames. */
195
+ export function retainableBrowserScreenshotToolCall(name: string | null): boolean {
196
+ return (
197
+ name === "browser_screenshot" ||
198
+ name === "interaction__browser_screenshot" ||
199
+ name === "browser_observe" ||
200
+ name === "interaction__browser_observe" ||
201
+ name === "browser_act" ||
202
+ name === "interaction__browser_act"
190
203
  );
191
204
  }
192
205
 
@@ -12,7 +12,7 @@ import {
12
12
  type TurnToolCancellationFence,
13
13
  } from "@opengeni/runtime";
14
14
  import type { Settings } from "@opengeni/config";
15
- import type { RetainedArtifactMetadata } from "@opengeni/contracts";
15
+ import type { RetainedArtifactMetadata, RetainedSessionScreenshotKind } from "@opengeni/contracts";
16
16
  import type { ResumedTurnSandbox } from "../../sandbox-resume";
17
17
  import type { SharedActivityServices } from "../types";
18
18
  import {
@@ -79,6 +79,7 @@ export class TurnMediaArtifacts {
79
79
  sandboxFileDownloadBackend: Settings["sandboxBackend"];
80
80
  nativeImageGenerationRetention: NativeImageGenerationRetention | null = null;
81
81
  readonly retainedSessionImageCallIds = new Set<string>();
82
+ readonly retainedSessionImageKindsByCallId = new Map<string, RetainedSessionScreenshotKind>();
82
83
 
83
84
  constructor(private readonly deps: TurnMediaArtifactDeps) {
84
85
  this.sandboxFileDownloadBackend = deps.getModelRunSettings().sandboxBackend;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  advanceWorkspaceGeneration,
3
3
  verifyWorkspaceMutationSettlement,
4
- heartbeatLeaseHolder,
4
+ heartbeatLeaseHolderStatus,
5
5
  readLease,
6
6
  accrueWarmSeconds,
7
7
  SandboxWorkspaceMutationFencedError,
@@ -526,12 +526,15 @@ export function createSandboxTurnRuntime(deps: SandboxTurnRuntimeDeps) {
526
526
  // home turn that degraded to the cloud group box (swap-away / flag-off), that
527
527
  // is the deployment default (modal), so the fallback box is warm-metered at
528
528
  // the cloud rate instead of selfhosted's rate-0 (which would under-bill).
529
- const warmRate = sandboxWarmRateMicrosPerSecond(
530
- settings,
531
- warmBackend ?? (sandbox.established.backendId as Settings["sandboxBackend"]),
532
- );
529
+ const warmRate =
530
+ settings.sandboxWarmBillingMode === "usage_only"
531
+ ? 0
532
+ : sandboxWarmRateMicrosPerSecond(
533
+ settings,
534
+ warmBackend ?? (sandbox.established.backendId as Settings["sandboxBackend"]),
535
+ );
533
536
  sandboxState.leaseHeartbeatTimer = setInterval(() => {
534
- void heartbeatLeaseHolder(db, {
537
+ void heartbeatLeaseHolderStatus(db, {
535
538
  accountId: input.accountId,
536
539
  workspaceId: input.workspaceId,
537
540
  sandboxGroupId: heartbeatGroupId,
@@ -539,9 +542,17 @@ export function createSandboxTurnRuntime(deps: SandboxTurnRuntimeDeps) {
539
542
  holderId: heartbeatHolderId,
540
543
  leaseTtlMs: settings.sandboxLeaseTtlMs,
541
544
  expectedEpoch: heartbeatEpoch,
545
+ billingMode: settings.sandboxWarmBillingMode,
542
546
  })
543
- .then(async (alive) => {
544
- if (alive) return;
547
+ .then(async (status) => {
548
+ if (status.fence === "funding") {
549
+ stopLeaseHeartbeat();
550
+ sandboxRotationController.abort(
551
+ new Error("Insufficient OpenGeni credits to extend paid sandbox compute"),
552
+ );
553
+ return;
554
+ }
555
+ if (status.leaseExtended) return;
545
556
  const rotation = await beginRotationPreemption(sandbox, heartbeatEpoch, heartbeatGroupId);
546
557
  if (rotation === "not_rotating") {
547
558
  // The holder was reaped, the exact attempt closed, the epoch was
@@ -557,9 +568,14 @@ export function createSandboxTurnRuntime(deps: SandboxTurnRuntimeDeps) {
557
568
  sandboxGroupId: heartbeatGroupId,
558
569
  expectedEpoch: heartbeatEpoch,
559
570
  warmRateMicrosPerSecond: warmRate,
571
+ billingMode: settings.sandboxWarmBillingMode,
560
572
  subjectId: input.sessionId,
561
573
  })
562
- .then((result) => recordCreditMicros(observability, "usage", result.costMicros))
574
+ .then((result) => {
575
+ if (settings.sandboxWarmBillingMode === "credits") {
576
+ recordCreditMicros(observability, "usage", result.costMicros);
577
+ }
578
+ })
563
579
  .catch(() => undefined);
564
580
  // MID-SESSION snapshot (sandbox-file-persistence): while the turn holds
565
581
  // the box, fold a fresh /workspace snapshot onto the lease every
@@ -61,7 +61,7 @@ import {
61
61
  import {
62
62
  compactRetainedScreenshotHistory,
63
63
  sdkEventContainsInlineImage,
64
- retainComputerScreenshot,
64
+ retainSessionScreenshot,
65
65
  typedScreenshotFromSdkEvent,
66
66
  unavailableRetainedSessionImage,
67
67
  } from "../retained-screenshots";
@@ -108,6 +108,7 @@ import {
108
108
  import {
109
109
  pendingToolCallFromSdkEvent,
110
110
  toolCallProducesRetainableSessionImage,
111
+ retainableBrowserScreenshotToolCall,
111
112
  completedToolCallFromSdkEvent,
112
113
  } from "./history";
113
114
  import { checkpointHistoryBeforeProviderDispatch } from "./provider-dispatch-barrier";
@@ -1125,6 +1126,12 @@ export async function runTurnStreamAttempt(
1125
1126
  currentToolBatchCallIds.add(pendingToolCall.callId);
1126
1127
  if (toolCallProducesRetainableSessionImage(pendingToolCall.callName)) {
1127
1128
  media.retainedSessionImageCallIds.add(pendingToolCall.callId);
1129
+ if (retainableBrowserScreenshotToolCall(pendingToolCall.callName)) {
1130
+ media.retainedSessionImageKindsByCallId.set(
1131
+ pendingToolCall.callId,
1132
+ "browser_screenshot",
1133
+ );
1134
+ }
1128
1135
  }
1129
1136
  }
1130
1137
  const completedToolCall = completedToolCallFromSdkEvent(durableSdkEvent);
@@ -1147,6 +1154,7 @@ export async function runTurnStreamAttempt(
1147
1154
  toolCallId: completedToolCall.callId,
1148
1155
  toolOutputId: completedToolCall.callId,
1149
1156
  reason: "unsupported",
1157
+ kind: media.retainedSessionImageKindsByCallId.get(completedToolCall.callId),
1150
1158
  });
1151
1159
  media.retainedScreenshotReceiptsByCallId.set(
1152
1160
  completedToolCall.callId,
@@ -1169,12 +1177,13 @@ export async function runTurnStreamAttempt(
1169
1177
  toolCallId: typedScreenshot.callId,
1170
1178
  toolOutputId: typedScreenshot.toolOutputId,
1171
1179
  reason: "pending",
1180
+ kind: media.retainedSessionImageKindsByCallId.get(completedToolCall.callId),
1172
1181
  });
1173
1182
  media.retainedScreenshotReceiptsByCallId.set(
1174
1183
  completedToolCall.callId,
1175
1184
  retainedScreenshotMetadata,
1176
1185
  );
1177
- retainedScreenshotMetadata = await retainComputerScreenshot({
1186
+ retainedScreenshotMetadata = await retainSessionScreenshot({
1178
1187
  db,
1179
1188
  objectStorage,
1180
1189
  accountId: input.accountId,
@@ -1183,6 +1192,7 @@ export async function runTurnStreamAttempt(
1183
1192
  turnId: activeTurnId,
1184
1193
  attemptId: input.attemptId,
1185
1194
  output: typedScreenshot,
1195
+ kind: media.retainedSessionImageKindsByCallId.get(completedToolCall.callId),
1186
1196
  });
1187
1197
  media.retainedScreenshotReceiptsByCallId.set(
1188
1198
  completedToolCall.callId,
@@ -1339,6 +1349,7 @@ export async function runTurnStreamAttempt(
1339
1349
  }
1340
1350
  for (const callId of stableToolCallIdsToClear) {
1341
1351
  media.retainedSessionImageCallIds.delete(callId);
1352
+ media.retainedSessionImageKindsByCallId.delete(callId);
1342
1353
  }
1343
1354
  }
1344
1355
  }
@@ -1,15 +1,21 @@
1
1
  import { configuredStaticUsageLimits } from "@opengeni/config";
2
+ import { documentEmbeddingCostMicros, paidDocumentEmbedding } from "@opengeni/core";
2
3
  import {
4
+ applyCreditDebitAfterUse,
3
5
  appendKnowledgeIndexChunks,
4
6
  claimKnowledgeIndexJobs,
5
7
  completeKnowledgeIndexJob,
6
8
  continueKnowledgeIndexJob,
7
9
  deferKnowledgeIndexJob,
10
+ freezeKnowledgeIndexBillingMode,
8
11
  getBillingBalance,
12
+ guardPaidKnowledgeIndexPublication,
13
+ knowledgeIndexBillingActivationTime,
9
14
  readKnowledgeIndexSource,
10
15
  recordUsageEvent,
11
16
  sumUsageQuantity,
12
17
  withWorkspaceUsageLock,
18
+ waitKnowledgeIndexForFunding,
13
19
  } from "@opengeni/db";
14
20
  import type { DocumentServices } from "@opengeni/documents";
15
21
  import type { ControlActivityServices } from "./types";
@@ -18,11 +24,26 @@ export function createKnowledgeIndexingActivities(
18
24
  services: () => Promise<ControlActivityServices>,
19
25
  resolveDocumentServices?: () => Promise<DocumentServices>,
20
26
  ) {
27
+ // Unpaid modes retain a conservative first-poll cutoff; paid mode uses the
28
+ // operator's explicit timestamp so process restarts cannot change eligibility.
29
+ let activationTime: Promise<Date> | undefined;
21
30
  return {
22
31
  indexKnowledge: async () => {
23
32
  const result = { completed: 0, advanced: 0, deferred: 0, unavailable: 0 };
24
33
  if (!resolveDocumentServices) return result;
25
34
  const { db, settings, observability } = await services();
35
+ let policyActivatedAt: Date;
36
+ if (paidDocumentEmbedding(settings)) {
37
+ if (!settings.documentEmbeddingCreditsActivatedAt)
38
+ throw new Error("paid Knowledge embedding requires an activation cutoff");
39
+ policyActivatedAt = new Date(settings.documentEmbeddingCreditsActivatedAt);
40
+ } else {
41
+ activationTime ??= knowledgeIndexBillingActivationTime(db).catch((error) => {
42
+ activationTime = undefined;
43
+ throw error;
44
+ });
45
+ policyActivatedAt = await activationTime;
46
+ }
26
47
  const { embedder } = await resolveDocumentServices();
27
48
  const { knowledgeIndexChunks } = await import("@opengeni/documents");
28
49
  const claims = await claimKnowledgeIndexJobs(db, {
@@ -37,9 +58,9 @@ export function createKnowledgeIndexingActivities(
37
58
  result.unavailable++;
38
59
  continue;
39
60
  }
40
- // Reuse the existing embedding budget and usage ledger. Each batch's
41
- // checkpoint and charge commit together, so a restart never charges
42
- // twice for an accepted projection. Canonical text is never changed.
61
+ // The checkpoint, usage and post-use debit commit together. A new
62
+ // generation requires funding once; committed batches may finish even
63
+ // if their accumulated cost takes the balance below zero.
43
64
  await withWorkspaceUsageLock(db, source.billingWorkspaceId, async (lockedDb) => {
44
65
  const current = await readKnowledgeIndexSource(lockedDb, claim);
45
66
  if (!current) {
@@ -57,9 +78,43 @@ export function createKnowledgeIndexingActivities(
57
78
  chunks.push(chunk);
58
79
  }
59
80
  if (chunks.length) {
60
- if (settings.billingMode === "stripe" || settings.usageLimitsMode === "managed") {
81
+ const frozenPolicy = await freezeKnowledgeIndexBillingMode(
82
+ lockedDb,
83
+ claim,
84
+ // Deterministic embeddings incur no provider charge; a valid
85
+ // credits-mode config with zero tariff must still index them.
86
+ // OpenAI shadow keeps its price snapshot for internal estimates.
87
+ settings.documentEmbeddingProvider === "openai"
88
+ ? (settings.documentEmbeddingBillingMode ?? "usage_only")
89
+ : "usage_only",
90
+ policyActivatedAt,
91
+ settings.documentEmbeddingRateMicrosPerMillionBytes ?? 0,
92
+ );
93
+ if (frozenPolicy.mode === "awaiting_review") {
94
+ result.deferred++;
95
+ return;
96
+ }
97
+ if (frozenPolicy.mode === "obsolete") {
98
+ result.unavailable++;
99
+ return;
100
+ }
101
+ // An already-priced generation cannot become free when the
102
+ // operator turns off paid embedding. Retain its checkpoint and
103
+ // retry only after paid mode is restored; never call the provider
104
+ // or append a batch that the live policy would not debit.
105
+ if (frozenPolicy.mode === "credits" && !paidDocumentEmbedding(settings)) {
106
+ await deferKnowledgeIndexJob(lockedDb, claim);
107
+ result.deferred++;
108
+ return;
109
+ }
110
+ const paid = frozenPolicy.mode === "credits";
111
+ if (paid && current.nextIndex === 0) {
61
112
  const balance = await getBillingBalance(lockedDb, claim.accountId);
62
- if (balance.balanceMicros <= 0) throw new Error("insufficient OpenGeni credits");
113
+ if (balance.balanceMicros <= 0) {
114
+ await waitKnowledgeIndexForFunding(lockedDb, claim);
115
+ result.deferred++;
116
+ return;
117
+ }
63
118
  }
64
119
  if (settings.usageLimitsMode === "static" || settings.usageLimitsMode === "managed") {
65
120
  const limit =
@@ -75,9 +130,24 @@ export function createKnowledgeIndexingActivities(
75
130
  throw new Error("monthly document indexing limit reached");
76
131
  }
77
132
  }
78
- const vectors = await embedder.embedMany(chunks.map((chunk) => chunk.embeddingInput));
133
+ const inputs = chunks.map((chunk) => chunk.embeddingInput);
134
+ const bytes = inputs.reduce(
135
+ (sum, input) => sum + Buffer.byteLength(input, "utf8"),
136
+ 0,
137
+ );
138
+ const vectors = await embedder.embedMany(inputs);
79
139
  if (vectors.length !== chunks.length)
80
140
  throw new Error("Incomplete Knowledge embeddings");
141
+ // A reviewer may have rejected this revision during the provider
142
+ // call. The DB guard holds its publication row through settlement.
143
+ if (paid) {
144
+ const publication = await guardPaidKnowledgeIndexPublication(lockedDb, claim);
145
+ if (publication !== "published") {
146
+ if (publication === "obsolete") result.unavailable++;
147
+ else result.deferred++;
148
+ return;
149
+ }
150
+ }
81
151
  const appended = await appendKnowledgeIndexChunks(
82
152
  lockedDb,
83
153
  claim,
@@ -98,6 +168,63 @@ export function createKnowledgeIndexingActivities(
98
168
  sourceResourceId: claim.revisionId,
99
169
  idempotencyKey: `knowledge.indexed:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
100
170
  });
171
+ await recordUsageEvent(lockedDb, {
172
+ accountId: claim.accountId,
173
+ workspaceId: current.billingWorkspaceId,
174
+ eventType: "document.embedding_bytes",
175
+ quantity: bytes,
176
+ unit: "byte",
177
+ sourceResourceType: "knowledge_revision",
178
+ sourceResourceId: claim.revisionId,
179
+ idempotencyKey: `knowledge.embedding_bytes:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
180
+ });
181
+ if (frozenPolicy.mode === "shadow" && frozenPolicy.rateMicrosPerMillionBytes > 0) {
182
+ const estimate = documentEmbeddingCostMicros(
183
+ {
184
+ ...settings,
185
+ documentEmbeddingRateMicrosPerMillionBytes:
186
+ frozenPolicy.rateMicrosPerMillionBytes,
187
+ },
188
+ bytes,
189
+ );
190
+ if (estimate > 0)
191
+ await recordUsageEvent(lockedDb, {
192
+ accountId: claim.accountId,
193
+ workspaceId: current.billingWorkspaceId,
194
+ eventType: "document.embedding_shadow_estimate",
195
+ quantity: estimate,
196
+ unit: "micro_usd",
197
+ sourceResourceType: "knowledge_revision",
198
+ sourceResourceId: claim.revisionId,
199
+ idempotencyKey: `knowledge.embedding_shadow:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
200
+ });
201
+ }
202
+ if (paid) {
203
+ const cost = documentEmbeddingCostMicros(
204
+ {
205
+ ...settings,
206
+ documentEmbeddingRateMicrosPerMillionBytes:
207
+ frozenPolicy.rateMicrosPerMillionBytes,
208
+ },
209
+ bytes,
210
+ );
211
+ if (cost > 0)
212
+ await applyCreditDebitAfterUse(lockedDb, {
213
+ accountId: claim.accountId,
214
+ workspaceId: current.billingWorkspaceId,
215
+ type: "document_embedding_debit",
216
+ amountMicros: cost,
217
+ sourceType: "knowledge_revision",
218
+ sourceId: claim.revisionId,
219
+ idempotencyKey: `knowledge.embedding:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
220
+ metadata: {
221
+ model: claim.model,
222
+ bytes,
223
+ chunks: chunks.length,
224
+ rateMicrosPerMillionBytes: frozenPolicy.rateMicrosPerMillionBytes,
225
+ },
226
+ });
227
+ }
101
228
  }
102
229
  if (more) {
103
230
  await continueKnowledgeIndexJob(lockedDb, claim);