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

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 (74) 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/final-reply.d.ts +18 -0
  7. package/dist/activities/agent-turn/governance-model.d.ts +6 -0
  8. package/dist/activities/agent-turn/model-call-admission.d.ts +15 -0
  9. package/dist/activities/agent-turn/run-credentials.d.ts +2 -0
  10. package/dist/activities/agent-turn/sandbox-establish.d.ts +1 -0
  11. package/dist/activities/agent-turn/session-title.d.ts +3 -1
  12. package/dist/activities/agent-turn/stream-attempt.d.ts +1 -0
  13. package/dist/activities/agent-turn/turn-context.d.ts +1 -0
  14. package/dist/activities/automations.d.ts +3 -1
  15. package/dist/activities/browser-deadline-checkpoint.d.ts +29 -0
  16. package/dist/activities/goals.d.ts +8 -0
  17. package/dist/activities/run-credentials.d.ts +4 -2
  18. package/dist/activities/types.d.ts +1 -1
  19. package/dist/{activities-control-MHNA3CYV.js → activities-control-55ECL47G.js} +403 -116
  20. package/dist/activities-control-55ECL47G.js.map +1 -0
  21. package/dist/activities-control.d.ts +30 -0
  22. package/dist/{activities-turn-FQ5OO2WW.js → activities-turn-T7Y3BWYO.js} +678 -246
  23. package/dist/activities-turn-T7Y3BWYO.js.map +1 -0
  24. package/dist/activities.d.ts +30 -0
  25. package/dist/browser-deadline-checkpoint-contract.d.ts +2 -0
  26. package/dist/{chunk-RZAAGRFQ.js → chunk-OWNLATJL.js} +189 -80
  27. package/dist/chunk-OWNLATJL.js.map +1 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.js +45 -23
  30. package/dist/index.js.map +1 -1
  31. package/dist/sandbox-resume.d.ts +4 -1
  32. package/dist/workflow-bundle.js +72 -10
  33. package/dist/workflows/browser-deadline-checkpoint.d.ts +9 -0
  34. package/dist/workflows.d.ts +1 -0
  35. package/package.json +21 -20
  36. package/src/activities/agent-run-admission.ts +17 -1
  37. package/src/activities/agent-turn/admission.ts +64 -1
  38. package/src/activities/agent-turn/agent-build.ts +24 -3
  39. package/src/activities/agent-turn/claim.ts +56 -14
  40. package/src/activities/agent-turn/claude-usage-observer.ts +33 -1
  41. package/src/activities/agent-turn/compaction-prep.ts +67 -16
  42. package/src/activities/agent-turn/errors.ts +55 -0
  43. package/src/activities/agent-turn/failure-settlement.ts +26 -6
  44. package/src/activities/agent-turn/final-reply.ts +46 -0
  45. package/src/activities/agent-turn/governance-model.ts +12 -0
  46. package/src/activities/agent-turn/model-call-admission.ts +94 -0
  47. package/src/activities/agent-turn/run-credentials.ts +15 -3
  48. package/src/activities/agent-turn/run.ts +43 -5
  49. package/src/activities/agent-turn/sandbox-establish.ts +9 -5
  50. package/src/activities/agent-turn/session-title.ts +8 -2
  51. package/src/activities/agent-turn/stream-attempt.ts +144 -38
  52. package/src/activities/agent-turn/tool-environment.ts +51 -39
  53. package/src/activities/agent-turn/turn-context.ts +1 -0
  54. package/src/activities/automations.ts +55 -3
  55. package/src/activities/browser-deadline-checkpoint.ts +279 -0
  56. package/src/activities/capabilities.ts +52 -14
  57. package/src/activities/goals.ts +67 -95
  58. package/src/activities/knowledge-indexing.ts +29 -0
  59. package/src/activities/open-suffix-resume.ts +2 -1
  60. package/src/activities/run-credentials.ts +22 -16
  61. package/src/activities/scheduled-tasks.ts +83 -13
  62. package/src/activities/types.ts +1 -0
  63. package/src/activities/workspace-credential-provider.ts +12 -0
  64. package/src/activities-control.ts +2 -0
  65. package/src/activities.ts +3 -0
  66. package/src/browser-deadline-checkpoint-contract.ts +5 -0
  67. package/src/index.ts +53 -25
  68. package/src/sandbox-resume.ts +23 -8
  69. package/src/sandbox-routing.ts +4 -2
  70. package/src/workflows/browser-deadline-checkpoint.ts +50 -0
  71. package/src/workflows.ts +4 -0
  72. package/dist/activities-control-MHNA3CYV.js.map +0 -1
  73. package/dist/activities-turn-FQ5OO2WW.js.map +0 -1
  74. package/dist/chunk-RZAAGRFQ.js.map +0 -1
@@ -0,0 +1,279 @@
1
+ import { Context } from "@temporalio/activity";
2
+ import {
3
+ environmentsEncryptionKeyBytes,
4
+ resolveFirstPartyDelegationSecret,
5
+ type Settings,
6
+ } from "@opengeni/config";
7
+ import { BROWSER_STATE_ARTIFACT_CONTENT_TYPE } from "@opengeni/contracts";
8
+ import {
9
+ browserDeadlineCheckpoint,
10
+ clearSuspendedBrowserSessionController,
11
+ commitBrowserSessionSuspension,
12
+ dispatchBrowserSessionOperation,
13
+ failBrowserSessionSuspension,
14
+ listBrowserDeadlineCheckpoints,
15
+ readLease,
16
+ releaseLeaseHolder,
17
+ withRlsContext,
18
+ type BrowserDeadlineCheckpointTarget,
19
+ } from "@opengeni/db";
20
+ import { deriveBrowserControllerAdminToken } from "@opengeni/interaction/browser-controller-authority";
21
+ import {
22
+ browserStateArtifactAad,
23
+ browserStateManifestDigest,
24
+ browserStateObjectKey,
25
+ deriveBrowserStateDataKey,
26
+ wrapBrowserStateDataKey,
27
+ } from "@opengeni/interaction/browser-state-authority";
28
+ import {
29
+ BrowserControlClient,
30
+ BrowserControlRequestError,
31
+ createSandboxClientForBackend,
32
+ deserializeSandboxSessionStateEnvelope,
33
+ resumeExactSandboxSession,
34
+ type BrowserControlPlacementSession,
35
+ } from "@opengeni/runtime";
36
+ import type { ControlActivityServices } from "./types";
37
+
38
+ const UPLOAD_CLEANUP_GRACE_MS = 24 * 60 * 60_000;
39
+
40
+ function heartbeat(): void {
41
+ try {
42
+ Context.current().heartbeat();
43
+ } catch {
44
+ // Direct embedded calls have no Temporal context. In-flight controller
45
+ // operations still settle under their exact durable database claim.
46
+ }
47
+ }
48
+
49
+ async function withActivityHeartbeat<T>(run: () => Promise<T>): Promise<T> {
50
+ heartbeat();
51
+ const timer = setInterval(heartbeat, 5_000);
52
+ try {
53
+ return await run();
54
+ } finally {
55
+ clearInterval(timer);
56
+ }
57
+ }
58
+
59
+ type CheckpointController = Pick<BrowserControlClient, "captureState" | "endSession">;
60
+ type CheckpointPlacement = {
61
+ target: BrowserDeadlineCheckpointTarget;
62
+ lease: NonNullable<Awaited<ReturnType<typeof readLease>>>;
63
+ settings: Settings;
64
+ controllerRoot: string;
65
+ };
66
+
67
+ async function resumeCheckpointController({
68
+ target,
69
+ lease,
70
+ settings,
71
+ controllerRoot,
72
+ }: CheckpointPlacement): Promise<CheckpointController> {
73
+ // This edge cannot create a box or route through a user's current machine.
74
+ const provider = createSandboxClientForBackend("modal", settings) as Parameters<
75
+ typeof deserializeSandboxSessionStateEnvelope
76
+ >[0];
77
+ const envelope = lease.resumeState as { sessionState?: unknown };
78
+ const state = await deserializeSandboxSessionStateEnvelope(
79
+ provider,
80
+ envelope.sessionState ?? lease.resumeState,
81
+ target.instanceId,
82
+ );
83
+ const resumed = await resumeExactSandboxSession(provider, "modal", state, target.instanceId);
84
+ return new BrowserControlClient(resumed.session as BrowserControlPlacementSession, {
85
+ adminToken: deriveBrowserControllerAdminToken({
86
+ rootSecret: controllerRoot,
87
+ accountId: target.accountId,
88
+ workspaceId: target.workspaceId,
89
+ placement: { kind: "sandbox_group", sandboxGroupId: target.sandboxGroupId },
90
+ placementInstanceId: target.instanceId,
91
+ }),
92
+ });
93
+ }
94
+
95
+ export function createBrowserDeadlineCheckpointActivities(
96
+ services: () => Promise<ControlActivityServices>,
97
+ connectController: (
98
+ placement: CheckpointPlacement,
99
+ ) => Promise<CheckpointController> = resumeCheckpointController,
100
+ ) {
101
+ return {
102
+ async listDueBrowserCheckpoints() {
103
+ return await withActivityHeartbeat(async () => {
104
+ const { db, settings, objectStorage } = await services();
105
+ if (
106
+ !settings.sandboxOwnershipEnabled ||
107
+ !objectStorage ||
108
+ !resolveFirstPartyDelegationSecret(settings)
109
+ )
110
+ return [];
111
+ const root = environmentsEncryptionKeyBytes(settings);
112
+ if (!root) return [];
113
+ root.fill(0);
114
+ return await listBrowserDeadlineCheckpoints(db);
115
+ });
116
+ },
117
+ async checkpointBrowserBeforeDeadline(target: BrowserDeadlineCheckpointTarget) {
118
+ return await withActivityHeartbeat(async () => {
119
+ const { db, settings, objectStorage } = await services();
120
+ const rootKey = environmentsEncryptionKeyBytes(settings);
121
+ const controllerRoot = resolveFirstPartyDelegationSecret(settings);
122
+ if (!rootKey || !objectStorage || !controllerRoot) {
123
+ rootKey?.fill(0);
124
+ throw new Error("browser deadline checkpoint storage or encryption is unavailable");
125
+ }
126
+ let dataKey: Buffer | null = null;
127
+ let aad: Buffer | null = null;
128
+ let timer: ReturnType<typeof setInterval> | null = null;
129
+ try {
130
+ const claim = await browserDeadlineCheckpoint(db, target, { prepare: true, touch: true });
131
+ if (!claim) return { status: "skipped" as const };
132
+ let pulseRunning = false;
133
+ let authorityLost = false;
134
+ const pulse = async () => {
135
+ if (pulseRunning) return;
136
+ pulseRunning = true;
137
+ try {
138
+ if (!(await browserDeadlineCheckpoint(db, target, { touch: true })))
139
+ authorityLost = true;
140
+ } finally {
141
+ pulseRunning = false;
142
+ }
143
+ };
144
+ timer = setInterval(() => {
145
+ void pulse().catch(() => undefined);
146
+ }, 5_000);
147
+ const lease = await readLease(db, target.workspaceId, target.sandboxGroupId);
148
+ if (
149
+ !lease ||
150
+ lease.instanceId !== target.instanceId ||
151
+ lease.leaseEpoch !== target.leaseEpoch ||
152
+ !lease.resumeState
153
+ ) {
154
+ throw new Error("browser deadline checkpoint placement changed");
155
+ }
156
+ const placement = {
157
+ kind: "sandbox_group" as const,
158
+ sandboxGroupId: target.sandboxGroupId,
159
+ };
160
+ const client = await connectController({ target, lease, settings, controllerRoot });
161
+ if (authorityLost || !(await browserDeadlineCheckpoint(db, target, { touch: true }))) {
162
+ throw new Error("browser deadline checkpoint authority changed");
163
+ }
164
+ if (claim.state !== "completed") {
165
+ const scope = { ...target, operationId: claim.operationId };
166
+ const objectKey = browserStateObjectKey(target.workspaceId, claim.operationId);
167
+ dataKey = deriveBrowserStateDataKey(rootKey, { ...scope, objectKey });
168
+ aad = browserStateArtifactAad({ ...scope, objectKey });
169
+ await dispatchBrowserSessionOperation(db, {
170
+ ...scope,
171
+ deadlineTarget: target,
172
+ stateUpload: {
173
+ objectKey,
174
+ cleanupAfter: new Date(Date.now() + UPLOAD_CLEANUP_GRACE_MS),
175
+ },
176
+ });
177
+ const signed = await objectStorage.createPutUrl({
178
+ key: objectKey,
179
+ contentType: BROWSER_STATE_ARTIFACT_CONTENT_TYPE,
180
+ audience: "sandbox",
181
+ });
182
+ const receipt = await client
183
+ .captureState({
184
+ ...scope,
185
+ objectKey,
186
+ afterCapture: "stop",
187
+ dataKey,
188
+ aad,
189
+ upload: {
190
+ url: signed.url,
191
+ requiredHeaders: signed.requiredHeaders,
192
+ expiresAt: signed.expiresAt.toISOString(),
193
+ },
194
+ })
195
+ .catch(async (error: unknown) => {
196
+ if (
197
+ error instanceof BrowserControlRequestError &&
198
+ (error.error.code === "outcome_unknown" || !error.error.retryable)
199
+ ) {
200
+ await failBrowserSessionSuspension(db, { ...scope, error: error.error });
201
+ }
202
+ throw error;
203
+ });
204
+ if (authorityLost) throw new Error("browser deadline checkpoint authority changed");
205
+ const portable = receipt.manifest.profileCrypto !== "platform_bound";
206
+ await commitBrowserSessionSuspension(db, {
207
+ ...scope,
208
+ deadlineTarget: target,
209
+ artifact: {
210
+ kind: "chromium_profile",
211
+ format: receipt.format,
212
+ objectKey,
213
+ artifactDigest: receipt.artifactDigest,
214
+ contentDigest: receipt.contentDigest,
215
+ manifestDigest: browserStateManifestDigest(receipt.manifest),
216
+ sizeBytes: receipt.sizeBytes,
217
+ encryptedDataKey: wrapBrowserStateDataKey(rootKey, dataKey, {
218
+ ...scope,
219
+ objectKey,
220
+ artifactDigest: receipt.artifactDigest,
221
+ contentDigest: receipt.contentDigest,
222
+ }),
223
+ materialization: {
224
+ portability: portable ? "portable" : "placement_bound",
225
+ reason: portable
226
+ ? null
227
+ : "Profile encryption depends on the source operating-system credential store.",
228
+ platform: receipt.manifest.platform,
229
+ architecture: receipt.manifest.architecture,
230
+ engine: receipt.manifest.engine,
231
+ engineVersion: receipt.manifest.engineVersion,
232
+ driverId: receipt.manifest.driverId,
233
+ driverSchemaVersion: receipt.manifest.driverSchemaVersion,
234
+ profileCrypto: receipt.manifest.profileCrypto,
235
+ providerId: null,
236
+ placement: portable ? null : placement,
237
+ },
238
+ },
239
+ });
240
+ }
241
+ // Only remove the local profile after the encrypted artifact and its
242
+ // authority committed. A crash resumes this cleanup, never capture.
243
+ await client.endSession(target, { removeState: true }).catch((error: unknown) => {
244
+ if (!(error instanceof BrowserControlRequestError && error.status === 404)) throw error;
245
+ });
246
+ await withRlsContext(
247
+ db,
248
+ target,
249
+ async (tx) => {
250
+ if ((await browserDeadlineCheckpoint(tx, target))?.state !== "completed") {
251
+ throw new Error("browser deadline checkpoint cleanup authority changed");
252
+ }
253
+ await clearSuspendedBrowserSessionController(tx, {
254
+ ...target,
255
+ expectedControllerGeneration: target.controllerGeneration,
256
+ });
257
+ // One transaction prevents a human resume from acquiring a new
258
+ // same-browser holder between clearing and releasing the old one.
259
+ await releaseLeaseHolder(tx, {
260
+ ...target,
261
+ kind: "interaction",
262
+ holderId: `browser-session:${target.browserSessionId}`,
263
+ idleGraceMs: settings.sandboxIdleGraceMs,
264
+ });
265
+ },
266
+ undefined,
267
+ "none",
268
+ );
269
+ return { status: "suspended" as const };
270
+ } finally {
271
+ if (timer) clearInterval(timer);
272
+ dataKey?.fill(0);
273
+ aad?.fill(0);
274
+ rootKey.fill(0);
275
+ }
276
+ });
277
+ },
278
+ };
279
+ }
@@ -40,6 +40,8 @@ import {
40
40
  listWorkspaceProviderCustomModels,
41
41
  getWorkspaceProviderCustomModelForExecution,
42
42
  loadWorkspaceProviderApiKey,
43
+ loadClaudeSubscriptionUsageCredential,
44
+ assertModelConnectionAllowsTurn,
43
45
  type Database,
44
46
  type SessionMcpServerForRun,
45
47
  } from "@opengeni/db";
@@ -303,12 +305,30 @@ export async function settingsWithOrganizationProviderCredentials(
303
305
  if (kind === "claude_subscription" && !settings.claudeSubscriptionEnabled) continue;
304
306
  const models = await buildModels(kind, claudeProviderId(kind) + "/");
305
307
  result = withClaudeConnectionCatalog(result, { [kind]: { models } });
306
- const credential = await loadOrganizationModelProviderApiKey(db, settings, {
307
- accountId,
308
- workspaceId,
309
- providerKind: kind,
310
- });
311
- if (credential) result = withClaudeConnectionCredential(result, kind, credential);
308
+ const binding =
309
+ kind === "claude_subscription"
310
+ ? await loadClaudeSubscriptionUsageCredential(db, settings, {
311
+ accountId,
312
+ workspaceId,
313
+ scope: "organization",
314
+ })
315
+ : null;
316
+ const credential =
317
+ kind === "claude_subscription"
318
+ ? binding?.serializedCredential
319
+ : await loadOrganizationModelProviderApiKey(db, settings, {
320
+ accountId,
321
+ workspaceId,
322
+ providerKind: kind,
323
+ });
324
+ if (credential)
325
+ result = withClaudeConnectionCredential(
326
+ result,
327
+ kind,
328
+ credential,
329
+ "organization",
330
+ binding ?? undefined,
331
+ );
312
332
  const workspaceModels = await listWorkspaceProviderCustomModels(db, {
313
333
  accountId,
314
334
  workspaceId,
@@ -333,15 +353,33 @@ export async function settingsWithOrganizationProviderCredentials(
333
353
  { [kind]: { models: workspaceModels } },
334
354
  "workspace",
335
355
  );
336
- const workspaceCredential = await loadWorkspaceProviderApiKey(
337
- db,
338
- settings,
339
- workspaceId,
340
- kind,
341
- workspaceModelId,
342
- );
356
+ const workspaceBinding =
357
+ kind === "claude_subscription"
358
+ ? await loadClaudeSubscriptionUsageCredential(db, settings, {
359
+ accountId,
360
+ workspaceId,
361
+ scope: "workspace",
362
+ })
363
+ : null;
364
+ if (workspaceBinding && workspaceModelId)
365
+ await assertModelConnectionAllowsTurn(db, {
366
+ workspaceId,
367
+ subjectId: "worker:model-access",
368
+ modelId: workspaceModelId,
369
+ workspaceProviderConnectionId: workspaceBinding.connectionId,
370
+ });
371
+ const workspaceCredential =
372
+ kind === "claude_subscription"
373
+ ? workspaceBinding?.serializedCredential
374
+ : await loadWorkspaceProviderApiKey(db, settings, workspaceId, kind, workspaceModelId);
343
375
  if (workspaceCredential)
344
- result = withClaudeConnectionCredential(result, kind, workspaceCredential, "workspace");
376
+ result = withClaudeConnectionCredential(
377
+ result,
378
+ kind,
379
+ workspaceCredential,
380
+ "workspace",
381
+ workspaceBinding ?? undefined,
382
+ );
345
383
  }
346
384
  return result;
347
385
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  allowedFirstPartyMcpToolsForSession,
3
3
  configuredStaticUsageLimits,
4
+ isModelAvailableForNewSelection,
4
5
  policyProviderIdForModel,
5
6
  resolveModelProvider,
6
7
  resolveTurnExecutionPolicyV1,
@@ -19,14 +20,10 @@ import {
19
20
  import { isCodexBilledModel } from "@opengeni/codex";
20
21
  import {
21
22
  enqueueSessionWorkflowWakeIfRunnable,
22
- getBillingBalance,
23
23
  getWorkspaceModelPolicy,
24
24
  getSessionGoal,
25
- isCodexBilledTurn,
26
25
  materializeGoalContinuation,
27
26
  requireSession,
28
- sumUsageQuantity,
29
- type Database,
30
27
  } from "@opengeni/db";
31
28
  import type {
32
29
  ControlActivityServices,
@@ -38,6 +35,7 @@ import {
38
35
  resolveCatalogSettings,
39
36
  resolveWorkspaceCatalogSettings,
40
37
  } from "@opengeni/core";
38
+ import { agentRunAdmissionDenial } from "./agent-run-admission";
41
39
 
42
40
  export function createGoalActivities(services: () => Promise<ControlActivityServices>) {
43
41
  async function enqueueGoalRetryWake(input: MaybeContinueGoalInput): Promise<void> {
@@ -65,7 +63,6 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
65
63
  if (!existingGoal || existingGoal.status !== "active") {
66
64
  return { action: "none" };
67
65
  }
68
- let settings = (await resolveCatalogSettings(db, catalogSourceSettings)).settings;
69
66
  // Loaded before the budget check so the codex-billed predicate and the
70
67
  // synthesized turn use the SAME effective policy. An explicit per-turn
71
68
  // model can differ from the persisted session default; follow-up goal work
@@ -74,6 +71,13 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
74
71
  // Kept below the goal-less fast path so a non-goal session still skips the
75
72
  // reads entirely.
76
73
  const session = await requireSession(db, input.workspaceId, input.sessionId);
74
+ // Terminal sessions retain their goal for human recovery, but cannot
75
+ // continue. Do not validate an obsolete model before the locked guard gets
76
+ // the chance to reject that work; otherwise a deterministic error retries.
77
+ if (session.status === "failed" || session.status === "cancelled") {
78
+ return { action: "none" };
79
+ }
80
+ let settings = (await resolveCatalogSettings(db, catalogSourceSettings)).settings;
77
81
  const inheritedContinuationModel = session.model;
78
82
  let continuationModel = inheritedContinuationModel;
79
83
  const continuationReasoningEffort = session.reasoningEffort;
@@ -109,39 +113,17 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
109
113
  ) {
110
114
  modelPolicyBlocked = `session is locked to Codex remote compaction v2; model "${continuationModel}" is not a Codex subscription model`;
111
115
  }
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
- 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
- reasoningEffort: continuationReasoningEffort,
141
- reasoningSource: "continuation",
142
- latencyMode: continuationLatencyMode,
143
- latencyModeSource: "continuation",
144
- });
116
+ const turnExecutionPolicy = modelPolicyBlocked
117
+ ? undefined
118
+ : resolveTurnExecutionPolicyV1(settings, {
119
+ modelId: continuationModel,
120
+ requestedModelId: null,
121
+ modelSource: "continuation",
122
+ reasoningEffort: continuationReasoningEffort,
123
+ reasoningSource: "continuation",
124
+ latencyMode: continuationLatencyMode,
125
+ latencyModeSource: "continuation",
126
+ });
145
127
  const decision = await materializeGoalContinuation(db, {
146
128
  accountId: input.accountId,
147
129
  workspaceId: input.workspaceId,
@@ -157,12 +139,30 @@ export function createGoalActivities(services: () => Promise<ControlActivityServ
157
139
  // A model-policy block takes precedence: it is deterministic (a budget
158
140
  // pause can clear on its own; a policy pause needs a model/policy change)
159
141
  // and rides the same visible-pause channel.
160
- budgetBlocked: modelPolicyBlocked ?? budgetBlocked,
142
+ admission: async (tx, causalTurn) => {
143
+ const budgetBlocked = modelPolicyBlocked
144
+ ? null
145
+ : await goalRunBudgetBlocked(
146
+ { ...service, settings, db: tx },
147
+ {
148
+ accountId: input.accountId,
149
+ workspaceId: input.workspaceId,
150
+ model: continuationModel,
151
+ initiatingHumanSubjectId: causalTurn?.initiatingHumanSubjectId ?? null,
152
+ },
153
+ );
154
+ return {
155
+ budgetBlocked: modelPolicyBlocked ?? budgetBlocked?.message ?? null,
156
+ budgetPausedReason: modelPolicyBlocked
157
+ ? "limits"
158
+ : (budgetBlocked?.pausedReason ?? "limits"),
159
+ };
160
+ },
161
161
  policy: {
162
162
  model: continuationModel,
163
163
  reasoningEffort: continuationReasoningEffort,
164
164
  latencyMode: continuationLatencyMode,
165
- turnExecutionPolicy,
165
+ ...(turnExecutionPolicy ? { turnExecutionPolicy } : {}),
166
166
  tools: withFirstPartyTools(settings, session.tools),
167
167
  sandboxBackend: session.sandboxBackend,
168
168
  },
@@ -215,18 +215,21 @@ export function goalContinuationModelDecision(input: {
215
215
  providerId: policyProviderIdForModel(catalogSettings, modelId),
216
216
  modelId,
217
217
  }).allowed;
218
- if (
219
- resolveModelProvider(catalogSettings, input.inheritedModel) &&
220
- !policyBlocks(input.inheritedModel)
221
- ) {
222
- return { model: input.inheritedModel, blocked: null };
223
- }
224
218
  if (!resolveModelProvider(catalogSettings, input.inheritedModel)) {
225
219
  return {
226
220
  model: input.inheritedModel,
227
221
  blocked: `model "${input.inheritedModel}" is no longer in the deployment or workspace catalog; choose an available model before resuming the goal`,
228
222
  };
229
223
  }
224
+ if (!isModelAvailableForNewSelection(catalogSettings, input.inheritedModel)) {
225
+ return {
226
+ model: input.inheritedModel,
227
+ blocked: `model "${input.inheritedModel}" is retired from new selection; choose an available model before resuming the goal`,
228
+ };
229
+ }
230
+ if (!policyBlocks(input.inheritedModel)) {
231
+ return { model: input.inheritedModel, blocked: null };
232
+ }
230
233
  return {
231
234
  model: input.inheritedModel,
232
235
  blocked: `workspace model policy blocks model "${input.inheritedModel}"; pick an allowed model or change the workspace model policy`,
@@ -307,6 +310,8 @@ export function goalContinuationPrompt(
307
310
  "- 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
311
  "",
309
312
  "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.",
313
+ "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.",
314
+ "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
315
  "",
311
316
  ...waitingGuidance,
312
317
  ...childNoticeGuidance,
@@ -336,55 +341,22 @@ export function withFirstPartyTools(settings: Settings, tools: ToolRef[]): ToolR
336
341
  }
337
342
 
338
343
  /**
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.
344
+ * Goals share scheduled admission and pause visibly without synthesizing work.
341
345
  */
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
- }
346
+ export async function goalRunBudgetBlocked(
347
+ services: Parameters<typeof agentRunAdmissionDenial>[0],
348
+ input: Omit<Parameters<typeof agentRunAdmissionDenial>[1], "requestedAgentRuns">,
349
+ ): Promise<{ pausedReason: "limits" | "allowance"; message: string } | null> {
350
+ const denial = await agentRunAdmissionDenial(services, { ...input, requestedAgentRuns: 1 });
351
+ if (denial === null) return null;
352
+ if (denial === "allowance_exhausted") {
353
+ return { pausedReason: "allowance", message: "OpenGeni usage allowance exhausted" };
383
354
  }
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));
355
+ const limits = configuredStaticUsageLimits(services.settings);
356
+ const messages = {
357
+ insufficient_credits: "insufficient OpenGeni credits",
358
+ monthly_model_cost_limit: `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`,
359
+ monthly_agent_run_limit: `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`,
360
+ };
361
+ return { pausedReason: "limits", message: messages[denial] };
390
362
  }
@@ -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,