@opengeni/worker-bundle 2.2.0 → 2.2.1-canary.36927100590001

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 (88) hide show
  1. package/dist/activities/agent-run-admission.d.ts +3 -1
  2. package/dist/activities/agent-turn/admission.d.ts +24 -3
  3. package/dist/activities/agent-turn/agent-build.d.ts +1 -0
  4. package/dist/activities/agent-turn/claude-usage-observer.d.ts +13 -1
  5. package/dist/activities/agent-turn/compaction-prep.d.ts +20 -0
  6. package/dist/activities/agent-turn/credential-restriction.d.ts +3 -0
  7. package/dist/activities/agent-turn/final-reply.d.ts +18 -0
  8. package/dist/activities/agent-turn/governance-model.d.ts +6 -0
  9. package/dist/activities/agent-turn/model-call-admission.d.ts +15 -0
  10. package/dist/activities/agent-turn/run-credentials.d.ts +4 -0
  11. package/dist/activities/agent-turn/sandbox-establish.d.ts +1 -0
  12. package/dist/activities/agent-turn/session-title.d.ts +3 -1
  13. package/dist/activities/agent-turn/stream-attempt.d.ts +1 -0
  14. package/dist/activities/agent-turn/turn-context.d.ts +1 -0
  15. package/dist/activities/automations.d.ts +3 -1
  16. package/dist/activities/browser-deadline-checkpoint.d.ts +29 -0
  17. package/dist/activities/goals.d.ts +8 -0
  18. package/dist/activities/run-credentials.d.ts +4 -2
  19. package/dist/activities/scheduled-tasks.d.ts +4 -35
  20. package/dist/activities/types.d.ts +4 -1
  21. package/dist/activities/workspace-capture.d.ts +2 -0
  22. package/dist/{activities-control-MHNA3CYV.js → activities-control-4F2A734B.js} +502 -133
  23. package/dist/activities-control-4F2A734B.js.map +1 -0
  24. package/dist/activities-control.d.ts +30 -0
  25. package/dist/{activities-turn-FQ5OO2WW.js → activities-turn-TESOIOEX.js} +696 -251
  26. package/dist/activities-turn-TESOIOEX.js.map +1 -0
  27. package/dist/activities.d.ts +30 -0
  28. package/dist/browser-deadline-checkpoint-contract.d.ts +2 -0
  29. package/dist/{chunk-RZAAGRFQ.js → chunk-4PJTUBWQ.js} +220 -88
  30. package/dist/chunk-4PJTUBWQ.js.map +1 -0
  31. package/dist/{chunk-IJZU752L.js → chunk-YG27NEMX.js} +34 -1
  32. package/dist/chunk-YG27NEMX.js.map +1 -0
  33. package/dist/index.d.ts +15 -0
  34. package/dist/index.js +46 -24
  35. package/dist/index.js.map +1 -1
  36. package/dist/observability-metrics.d.ts +16 -1
  37. package/dist/sandbox-resume.d.ts +4 -1
  38. package/dist/workflow-bundle.js +89 -21
  39. package/dist/workflows/browser-deadline-checkpoint.d.ts +9 -0
  40. package/dist/workflows/scheduled-tasks.d.ts +4 -0
  41. package/dist/workflows.d.ts +1 -0
  42. package/package.json +21 -20
  43. package/src/activities/agent-run-admission.ts +17 -1
  44. package/src/activities/agent-turn/admission.ts +64 -1
  45. package/src/activities/agent-turn/agent-build.ts +24 -3
  46. package/src/activities/agent-turn/claim.ts +69 -15
  47. package/src/activities/agent-turn/claude-usage-observer.ts +33 -1
  48. package/src/activities/agent-turn/compaction-prep.ts +67 -16
  49. package/src/activities/agent-turn/credential-restriction.ts +14 -0
  50. package/src/activities/agent-turn/errors.ts +55 -0
  51. package/src/activities/agent-turn/failure-settlement.ts +26 -6
  52. package/src/activities/agent-turn/final-reply.ts +46 -0
  53. package/src/activities/agent-turn/governance-model.ts +12 -0
  54. package/src/activities/agent-turn/model-call-admission.ts +94 -0
  55. package/src/activities/agent-turn/run-credentials.ts +20 -3
  56. package/src/activities/agent-turn/run.ts +44 -5
  57. package/src/activities/agent-turn/sandbox-establish.ts +9 -5
  58. package/src/activities/agent-turn/session-title.ts +8 -2
  59. package/src/activities/agent-turn/stream-attempt.ts +144 -38
  60. package/src/activities/agent-turn/tool-environment.ts +55 -39
  61. package/src/activities/agent-turn/turn-context.ts +1 -0
  62. package/src/activities/automations.ts +78 -4
  63. package/src/activities/browser-deadline-checkpoint.ts +279 -0
  64. package/src/activities/capabilities.ts +52 -14
  65. package/src/activities/goals.ts +95 -98
  66. package/src/activities/knowledge-indexing.ts +29 -0
  67. package/src/activities/open-suffix-resume.ts +2 -1
  68. package/src/activities/run-credentials.ts +22 -16
  69. package/src/activities/sandbox-lease.ts +22 -0
  70. package/src/activities/scheduled-tasks.ts +172 -38
  71. package/src/activities/site-auth-maintenance.ts +1 -0
  72. package/src/activities/types.ts +4 -0
  73. package/src/activities/workspace-capture.ts +13 -2
  74. package/src/activities/workspace-credential-provider.ts +12 -0
  75. package/src/activities-control.ts +2 -0
  76. package/src/activities.ts +3 -0
  77. package/src/browser-deadline-checkpoint-contract.ts +5 -0
  78. package/src/index.ts +53 -25
  79. package/src/observability-metrics.ts +55 -1
  80. package/src/sandbox-resume.ts +23 -8
  81. package/src/sandbox-routing.ts +4 -2
  82. package/src/workflows/browser-deadline-checkpoint.ts +50 -0
  83. package/src/workflows/scheduled-tasks.ts +26 -13
  84. package/src/workflows.ts +4 -0
  85. package/dist/activities-control-MHNA3CYV.js.map +0 -1
  86. package/dist/activities-turn-FQ5OO2WW.js.map +0 -1
  87. package/dist/chunk-IJZU752L.js.map +0 -1
  88. package/dist/chunk-RZAAGRFQ.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  allowedFirstPartyMcpToolsForSession,
3
3
  configuredStaticUsageLimits,
4
+ isModelAvailableForNewSelection,
4
5
  policyProviderIdForModel,
5
6
  resolveModelProvider,
6
7
  resolveTurnExecutionPolicyV1,
@@ -13,20 +14,18 @@ import {
13
14
  import {
14
15
  evaluateWorkspaceModelPolicy,
15
16
  mergeToolRefs,
17
+ readTurnExecutionPolicyV1,
16
18
  type SessionGoal,
17
19
  type ToolRef,
18
20
  } from "@opengeni/contracts";
19
21
  import { isCodexBilledModel } from "@opengeni/codex";
20
22
  import {
21
23
  enqueueSessionWorkflowWakeIfRunnable,
22
- getBillingBalance,
23
24
  getWorkspaceModelPolicy,
24
25
  getSessionGoal,
25
- isCodexBilledTurn,
26
+ getSessionTurn,
26
27
  materializeGoalContinuation,
27
28
  requireSession,
28
- sumUsageQuantity,
29
- type Database,
30
29
  } from "@opengeni/db";
31
30
  import type {
32
31
  ControlActivityServices,
@@ -38,6 +37,8 @@ import {
38
37
  resolveCatalogSettings,
39
38
  resolveWorkspaceCatalogSettings,
40
39
  } from "@opengeni/core";
40
+ import { agentRunAdmissionDenial } from "./agent-run-admission";
41
+ import { turnCredentialRestriction } from "./agent-turn/credential-restriction";
41
42
 
42
43
  export function createGoalActivities(services: () => Promise<ControlActivityServices>) {
43
44
  async function enqueueGoalRetryWake(input: MaybeContinueGoalInput): Promise<void> {
@@ -65,7 +66,6 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
65
66
  if (!existingGoal || existingGoal.status !== "active") {
66
67
  return { action: "none" };
67
68
  }
68
- let settings = (await resolveCatalogSettings(db, catalogSourceSettings)).settings;
69
69
  // Loaded before the budget check so the codex-billed predicate and the
70
70
  // synthesized turn use the SAME effective policy. An explicit per-turn
71
71
  // model can differ from the persisted session default; follow-up goal work
@@ -74,6 +74,13 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
74
74
  // Kept below the goal-less fast path so a non-goal session still skips the
75
75
  // reads entirely.
76
76
  const session = await requireSession(db, input.workspaceId, input.sessionId);
77
+ // Terminal sessions retain their goal for human recovery, but cannot
78
+ // continue. Do not validate an obsolete model before the locked guard gets
79
+ // the chance to reject that work; otherwise a deterministic error retries.
80
+ if (session.status === "failed" || session.status === "cancelled") {
81
+ return { action: "none" };
82
+ }
83
+ let settings = (await resolveCatalogSettings(db, catalogSourceSettings)).settings;
77
84
  const inheritedContinuationModel = session.model;
78
85
  let continuationModel = inheritedContinuationModel;
79
86
  const continuationReasoningEffort = session.reasoningEffort;
@@ -109,39 +116,27 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
109
116
  ) {
110
117
  modelPolicyBlocked = `session is locked to Codex remote compaction v2; model "${continuationModel}" is not a Codex subscription model`;
111
118
  }
112
- // A codex-model goal continuation is paid by the user's ChatGPT/Codex plan,
113
- // so it must not be budget-paused for zero OpenGeni credits. This file uses
114
- // BASE settings (no codex overlay); the predicate does its own credential read.
115
- const isCodexRun = await isCodexBilledTurn({
116
- db,
117
- settings,
118
- workspaceId: input.workspaceId,
119
+ const turnExecutionPolicy = modelPolicyBlocked
120
+ ? undefined
121
+ : resolveTurnExecutionPolicyV1(settings, {
122
+ modelId: continuationModel,
123
+ requestedModelId: null,
124
+ modelSource: "continuation",
125
+ reasoningEffort: continuationReasoningEffort,
126
+ reasoningSource: "continuation",
127
+ latencyMode: continuationLatencyMode,
128
+ latencyModeSource: "continuation",
129
+ });
130
+ const continuationPolicy: NonNullable<
131
+ Parameters<typeof materializeGoalContinuation>[1]["policy"]
132
+ > = {
119
133
  model: continuationModel,
120
- });
121
- const fundedWithoutCredits = goalContinuationFundedWithoutCredits(
122
- settings,
123
- continuationModel,
124
- isCodexRun,
125
- );
126
- // Budget exhaustion pauses the goal visibly instead of failing the
127
- // session. Computed up front and applied inside the locked decision so a
128
- // limits pause never consumes continuation budget.
129
- const budgetBlocked = await goalRunBudgetBlocked(
130
- settings,
131
- db,
132
- input.accountId,
133
- input.workspaceId,
134
- fundedWithoutCredits,
135
- );
136
- const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
137
- modelId: continuationModel,
138
- requestedModelId: null,
139
- modelSource: "continuation",
140
134
  reasoningEffort: continuationReasoningEffort,
141
- reasoningSource: "continuation",
142
135
  latencyMode: continuationLatencyMode,
143
- latencyModeSource: "continuation",
144
- });
136
+ ...(turnExecutionPolicy ? { turnExecutionPolicy } : {}),
137
+ tools: withFirstPartyTools(settings, session.tools),
138
+ sandboxBackend: session.sandboxBackend,
139
+ };
145
140
  const decision = await materializeGoalContinuation(db, {
146
141
  accountId: input.accountId,
147
142
  workspaceId: input.workspaceId,
@@ -157,15 +152,45 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
157
152
  // A model-policy block takes precedence: it is deterministic (a budget
158
153
  // pause can clear on its own; a policy pause needs a model/policy change)
159
154
  // and rides the same visible-pause channel.
160
- budgetBlocked: modelPolicyBlocked ?? budgetBlocked,
161
- policy: {
162
- model: continuationModel,
163
- reasoningEffort: continuationReasoningEffort,
164
- latencyMode: continuationLatencyMode,
165
- turnExecutionPolicy,
166
- tools: withFirstPartyTools(settings, session.tools),
167
- sandboxBackend: session.sandboxBackend,
155
+ admission: async (tx, causalTurn) => {
156
+ // The materializer selects this exact causal row under its session/goal
157
+ // locks and freezes policy only after admission returns. Never infer
158
+ // credential authority from the generated continuation's payload.
159
+ const sourceTurn = causalTurn
160
+ ? await getSessionTurn(tx, input.workspaceId, causalTurn.id)
161
+ : null;
162
+ if (causalTurn && (!sourceTurn || sourceTurn.sessionId !== input.sessionId)) {
163
+ throw new Error("Goal continuation source turn is unavailable");
164
+ }
165
+ const sourcePolicy = readTurnExecutionPolicyV1(sourceTurn?.metadata);
166
+ if (turnExecutionPolicy) {
167
+ const credentialRestriction = turnCredentialRestriction(
168
+ sourcePolicy.kind === "valid" ? sourcePolicy.policy : turnExecutionPolicy,
169
+ session.metadata,
170
+ );
171
+ continuationPolicy.turnExecutionPolicy = credentialRestriction
172
+ ? { ...turnExecutionPolicy, credentialRestriction }
173
+ : turnExecutionPolicy;
174
+ }
175
+ const budgetBlocked = modelPolicyBlocked
176
+ ? null
177
+ : await goalRunBudgetBlocked(
178
+ { ...service, settings, db: tx },
179
+ {
180
+ accountId: input.accountId,
181
+ workspaceId: input.workspaceId,
182
+ model: continuationModel,
183
+ initiatingHumanSubjectId: causalTurn?.initiatingHumanSubjectId ?? null,
184
+ },
185
+ );
186
+ return {
187
+ budgetBlocked: modelPolicyBlocked ?? budgetBlocked?.message ?? null,
188
+ budgetPausedReason: modelPolicyBlocked
189
+ ? "limits"
190
+ : (budgetBlocked?.pausedReason ?? "limits"),
191
+ };
168
192
  },
193
+ policy: continuationPolicy,
169
194
  // Long-wait guidance is only given when `wait_for_input` is actually in this
170
195
  // session's effective first-party selection (the same source the worker
171
196
  // signs into the delegated token and the API uses to register tools), so
@@ -215,18 +240,21 @@ export function goalContinuationModelDecision(input: {
215
240
  providerId: policyProviderIdForModel(catalogSettings, modelId),
216
241
  modelId,
217
242
  }).allowed;
218
- if (
219
- resolveModelProvider(catalogSettings, input.inheritedModel) &&
220
- !policyBlocks(input.inheritedModel)
221
- ) {
222
- return { model: input.inheritedModel, blocked: null };
223
- }
224
243
  if (!resolveModelProvider(catalogSettings, input.inheritedModel)) {
225
244
  return {
226
245
  model: input.inheritedModel,
227
246
  blocked: `model "${input.inheritedModel}" is no longer in the deployment or workspace catalog; choose an available model before resuming the goal`,
228
247
  };
229
248
  }
249
+ if (!isModelAvailableForNewSelection(catalogSettings, input.inheritedModel)) {
250
+ return {
251
+ model: input.inheritedModel,
252
+ blocked: `model "${input.inheritedModel}" is retired from new selection; choose an available model before resuming the goal`,
253
+ };
254
+ }
255
+ if (!policyBlocks(input.inheritedModel)) {
256
+ return { model: input.inheritedModel, blocked: null };
257
+ }
230
258
  return {
231
259
  model: input.inheritedModel,
232
260
  blocked: `workspace model policy blocks model "${input.inheritedModel}"; pick an allowed model or change the workspace model policy`,
@@ -307,6 +335,8 @@ export function goalContinuationPrompt(
307
335
  "- For document report deliverables (a document the user asked for, or a large report meant to be kept or shared), follow the Documents Skill: create the durable native document first, inspect its relevant final head after the last edit, and provide the returned artifact reference. Declare report requirements through the available goal tools before authoring and satisfy every persisted report requirement with verified artifact delivery evidence before completion. Sandbox paths and raw file IDs do not prove report delivery. If artifact tooling or access is unavailable, keep that deliverable incomplete and state the blocker; never invent proof or silently substitute a local report. Ordinary chat answers, short progress updates, source-code links, and explicitly requested local-file work remain outside this report contract.",
308
336
  "",
309
337
  "Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. Call opengeni__goal_complete with concrete evidence only when the full objective is actually achieved and no required work remains.",
338
+ "Goal evidence is a short proof for the ledger, not the deliverable. After goal_complete succeeds, finish this same turn with the requested user-facing answer, or a concise summary and retained artifact link. Goal completion stops future automatic continuations; it does not send the answer or end this turn. Never compress a report into evidence or omit the final reply.",
339
+ "Goal progress notes are short human-readable milestone statuses, not raw transcripts or continuation instructions. Keep normal spaces and summarize detail instead of squeezing words into a ledger field. The text and successCriteria fields each allow 8192 UTF-8 bytes, progressNote allows 8192 UTF-8 bytes, rationale allows 2048 UTF-8 bytes, and evidence allows 8192 characters.",
310
340
  "",
311
341
  ...waitingGuidance,
312
342
  ...childNoticeGuidance,
@@ -336,55 +366,22 @@ export function withFirstPartyTools(settings: Settings, tools: ToolRef[]): ToolR
336
366
  }
337
367
 
338
368
  /**
339
- * Non-throwing variant of the scheduled-run admission check: returns a human
340
- * readable reason when balance or monthly caps block another agent run.
369
+ * Goals share scheduled admission and pause visibly without synthesizing work.
341
370
  */
342
- async function goalRunBudgetBlocked(
343
- settings: Settings,
344
- db: Database,
345
- accountId: string,
346
- workspaceId: string,
347
- fundedWithoutCredits: boolean,
348
- ): Promise<string | null> {
349
- // Free, subscription, and workspace-funded continuations skip OpenGeni's
350
- // credit-balance gate and monthly model-cost cap. The agent-run COUNT cap
351
- // below is a volume quota (not a credit/cost gate) and remains enforced.
352
- if (
353
- !fundedWithoutCredits &&
354
- (settings.billingMode === "stripe" || settings.usageLimitsMode === "managed")
355
- ) {
356
- const balance = await getBillingBalance(db, accountId);
357
- if (balance.balanceMicros <= 0) {
358
- return "insufficient OpenGeni credits";
359
- }
360
- }
361
- if (settings.usageLimitsMode === "static" || settings.usageLimitsMode === "managed") {
362
- const limits = configuredStaticUsageLimits(settings);
363
- if (!fundedWithoutCredits && limits.maxMonthlyCostMicrosPerAccount) {
364
- const used = await sumUsageQuantity(db, {
365
- accountId,
366
- eventType: "model.cost",
367
- since: startOfUtcMonth(),
368
- });
369
- if (used >= limits.maxMonthlyCostMicrosPerAccount) {
370
- return `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`;
371
- }
372
- }
373
- if (limits.maxMonthlyAgentRunsPerWorkspace) {
374
- const used = await sumUsageQuantity(db, {
375
- workspaceId,
376
- eventType: "agent_run.created",
377
- since: startOfUtcMonth(),
378
- });
379
- if (used + 1 > limits.maxMonthlyAgentRunsPerWorkspace) {
380
- return `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`;
381
- }
382
- }
371
+ export async function goalRunBudgetBlocked(
372
+ services: Parameters<typeof agentRunAdmissionDenial>[0],
373
+ input: Omit<Parameters<typeof agentRunAdmissionDenial>[1], "requestedAgentRuns">,
374
+ ): Promise<{ pausedReason: "limits" | "allowance"; message: string } | null> {
375
+ const denial = await agentRunAdmissionDenial(services, { ...input, requestedAgentRuns: 1 });
376
+ if (denial === null) return null;
377
+ if (denial === "allowance_exhausted") {
378
+ return { pausedReason: "allowance", message: "OpenGeni usage allowance exhausted" };
383
379
  }
384
- return null;
385
- }
386
-
387
- function startOfUtcMonth(): Date {
388
- const now = new Date();
389
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
380
+ const limits = configuredStaticUsageLimits(services.settings);
381
+ const messages = {
382
+ insufficient_credits: "insufficient OpenGeni credits",
383
+ monthly_model_cost_limit: `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`,
384
+ monthly_agent_run_limit: `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`,
385
+ };
386
+ return { pausedReason: "limits", message: messages[denial] };
390
387
  }
@@ -4,8 +4,10 @@ import {
4
4
  applyCreditDebitAfterUse,
5
5
  appendKnowledgeIndexChunks,
6
6
  claimKnowledgeIndexJobs,
7
+ checkWorkspaceAllowance,
7
8
  completeKnowledgeIndexJob,
8
9
  continueKnowledgeIndexJob,
10
+ creditDebitAttributionMetadata,
9
11
  deferKnowledgeIndexJob,
10
12
  freezeKnowledgeIndexBillingMode,
11
13
  getBillingBalance,
@@ -198,6 +200,16 @@ export function createKnowledgeIndexingActivities(
198
200
  return;
199
201
  }
200
202
  const paid = frozenPolicy.mode === "credits";
203
+ if (paid && claim.billingAttribution.kind === "unknown") {
204
+ // Unknown legacy/source-preparation causality is not service.
205
+ // Keep the checkpoint without making an uncountable paid call.
206
+ observability.warn("Paid Knowledge indexing awaits initiating attribution", {
207
+ errorCode: "knowledge_index_attribution_unavailable",
208
+ });
209
+ await deferKnowledgeIndexJob(lockedDb, claim);
210
+ result.deferred++;
211
+ return;
212
+ }
201
213
  if (paid && current.nextIndex === 0) {
202
214
  const balance = await getBillingBalance(lockedDb, claim.accountId);
203
215
  if (balance.balanceMicros <= 0) {
@@ -206,6 +218,22 @@ export function createKnowledgeIndexingActivities(
206
218
  return;
207
219
  }
208
220
  }
221
+ if (paid) {
222
+ const refusal = await checkWorkspaceAllowance(lockedDb, {
223
+ accountId: claim.accountId,
224
+ workspaceId: current.billingWorkspaceId,
225
+ subjectId:
226
+ claim.billingAttribution.kind === "turn" ||
227
+ claim.billingAttribution.kind === "human"
228
+ ? claim.billingAttribution.initiatingHumanSubjectId
229
+ : null,
230
+ });
231
+ if (refusal) {
232
+ await deferKnowledgeIndexJob(lockedDb, claim);
233
+ result.deferred++;
234
+ return;
235
+ }
236
+ }
209
237
  if (settings.usageLimitsMode === "static" || settings.usageLimitsMode === "managed") {
210
238
  const limit =
211
239
  configuredStaticUsageLimits(settings).maxDocumentIndexedChunksPerWorkspace;
@@ -309,6 +337,7 @@ export function createKnowledgeIndexingActivities(
309
337
  sourceId: claim.revisionId,
310
338
  idempotencyKey: `knowledge.embedding:${claim.revisionId}:${claim.generation}:${current.nextIndex}`,
311
339
  metadata: {
340
+ ...creditDebitAttributionMetadata(claim.billingAttribution),
312
341
  model: claim.model,
313
342
  bytes,
314
343
  chunks: chunks.length,
@@ -2,6 +2,7 @@ import type { Agent } from "@openai/agents";
2
2
  import {
3
3
  OPEN_SUFFIX_RUN_STATE_BLOB,
4
4
  approvalIdentifier,
5
+ withPublicApprovalFields,
5
6
  type HumanInputResponse,
6
7
  type SessionEvent,
7
8
  } from "@opengeni/contracts";
@@ -380,7 +381,7 @@ export async function settleOpenSuffixResumeIfNeeded(input: {
380
381
  ? [
381
382
  {
382
383
  type: "session.requiresAction" as const,
383
- payload: { approvals: requiresActionApprovals },
384
+ payload: { approvals: requiresActionApprovals.map(withPublicApprovalFields) },
384
385
  },
385
386
  ]
386
387
  : []),
@@ -5,6 +5,7 @@ import type {
5
5
  RunCredentialsRequest,
6
6
  Session,
7
7
  SessionTurn,
8
+ ToolRef,
8
9
  } from "@opengeni/contracts";
9
10
  import type { Settings } from "@opengeni/config";
10
11
  import { getSessionRootId, type Database } from "@opengeni/db";
@@ -30,6 +31,8 @@ export type RunCredentialResolutionContext = {
30
31
  initiatingHumanSubjectId?: string | null;
31
32
  /** Installed in-process API routes must never enter a product callback. */
32
33
  localMcpServerIds?: readonly string[];
34
+ /** Exact execution selection, resolved once at the turn policy boundary. */
35
+ effectiveTools: readonly ToolRef[];
33
36
  };
34
37
 
35
38
  export type BoundRunCredentialResolver = {
@@ -42,7 +45,7 @@ export type BoundRunCredentialResolver = {
42
45
  export function buildRunCredentialsRequest(
43
46
  input: Omit<
44
47
  RunCredentialResolutionContext,
45
- "db" | "connectionCredentials" | "settings" | "initiatingHumanSubjectId"
48
+ "db" | "connectionCredentials" | "settings" | "initiatingHumanSubjectId" | "effectiveTools"
46
49
  > & {
47
50
  rootSessionId: string;
48
51
  purpose: "provision" | "renewal";
@@ -125,7 +128,7 @@ export async function bindRunCredentialResolver(
125
128
  mcpServers: selectedSessionRemoteMcpTargets(
126
129
  input.settings,
127
130
  input.session.mcpServers ?? [],
128
- input.turn.tools ?? [],
131
+ input.effectiveTools,
129
132
  (input.localMcpServerIds ?? []).map((id) => ({ id })),
130
133
  ),
131
134
  },
@@ -146,22 +149,25 @@ export async function bindRunCredentialResolver(
146
149
  workspaceId: input.workspaceId,
147
150
  sessionId: input.session.id,
148
151
  };
152
+ // A renewable resolver belongs to one admitted turn, even if its caller
153
+ // subsequently replaces or mutates an in-memory session/turn projection.
154
+ const frozenRequest = structuredClone(
155
+ buildRunCredentialsRequest({
156
+ accountId: input.accountId,
157
+ workspaceId: input.workspaceId,
158
+ session: input.session,
159
+ turn: input.turn,
160
+ attemptId: input.attemptId,
161
+ effectiveSandboxBackend: input.effectiveSandboxBackend,
162
+ variableSet: input.variableSet,
163
+ rootSessionId,
164
+ purpose: "provision",
165
+ forceRefresh: false,
166
+ }),
167
+ );
149
168
  return {
150
169
  resolve: async ({ purpose, forceRefresh }) => {
151
- const resolution = await resolver(
152
- buildRunCredentialsRequest({
153
- accountId: input.accountId,
154
- workspaceId: input.workspaceId,
155
- session: input.session,
156
- turn: input.turn,
157
- attemptId: input.attemptId,
158
- effectiveSandboxBackend: input.effectiveSandboxBackend,
159
- variableSet: input.variableSet,
160
- rootSessionId,
161
- purpose,
162
- forceRefresh,
163
- }),
164
- );
170
+ const resolution = await resolver({ ...frozenRequest, purpose, forceRefresh });
165
171
  return normalizeRunCredentialsResolution(resolution, scope);
166
172
  },
167
173
  };
@@ -41,6 +41,8 @@ import {
41
41
  deferRetainedProcessReconciliation,
42
42
  forceDrainOverLimitViewerOnlyBoxes,
43
43
  listCreditBalancesByAccount,
44
+ countActiveUsers,
45
+ readCreditGrantTotals,
44
46
  readVerifiedSignupTrialSwitch,
45
47
  listLegacyModalCheckpointSlots,
46
48
  listLiveModalSandboxLeaseAttributions,
@@ -150,7 +152,9 @@ import { assertSandboxDrainInputTiming, sandboxDrainTiming } from "../sandbox-re
150
152
  import type { ControlActivityServices as ActivityServices } from "./types";
151
153
  import { reconcilePendingParentSystemUpdates } from "./parent-wake";
152
154
  import {
155
+ recordActiveUserGauges,
153
156
  recordCreditBalanceGauges,
157
+ recordCreditGrantGauges,
154
158
  recordCreditMicros,
155
159
  recordExpiredDrainingSandboxLeaseGauges,
156
160
  recordOpenSandboxKubernetesInventoryGauges,
@@ -2525,6 +2529,24 @@ async function refreshQueueLeaseAndCreditGauges(
2525
2529
  });
2526
2530
  }
2527
2531
  })(),
2532
+ (async () => {
2533
+ try {
2534
+ recordActiveUserGauges(observability, await countActiveUsers(db));
2535
+ } catch (error) {
2536
+ observability.warn("sandbox reaper: active-user gauge refresh failed", {
2537
+ error: error instanceof Error ? error.message : String(error),
2538
+ });
2539
+ }
2540
+ })(),
2541
+ (async () => {
2542
+ try {
2543
+ recordCreditGrantGauges(observability, await readCreditGrantTotals(db));
2544
+ } catch (error) {
2545
+ observability.warn("sandbox reaper: credit-grant gauge refresh failed", {
2546
+ error: error instanceof Error ? error.message : String(error),
2547
+ });
2548
+ }
2549
+ })(),
2528
2550
  (async () => {
2529
2551
  recordVerifiedSignupTrialDeploymentFlagGauge(
2530
2552
  observability,