@opengeni/worker-bundle 1.3.0 → 1.4.2
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.
- package/dist/activities/agent-turn/errors.d.ts +1 -13
- package/dist/activities/agent-turn/sandbox-provision.d.ts +22 -0
- package/dist/activities/agent-turn/subscription-lease.d.ts +1 -0
- package/dist/activities/agent-turn/tool-policy.d.ts +10 -1
- package/dist/activities/agent-turn/turn-context.d.ts +6 -0
- package/dist/activities/agent-turn/web-search.d.ts +37 -0
- package/dist/activities/codemode-dispatcher.d.ts +8 -0
- package/dist/activities/sandbox-lease.d.ts +4 -0
- package/dist/{activities-control-PEXBWKQJ.js → activities-control-AKJZHDTY.js} +53 -13
- package/dist/activities-control-AKJZHDTY.js.map +1 -0
- package/dist/{activities-turn-W27A2SBO.js → activities-turn-AOXNE6BM.js} +5984 -5571
- package/dist/activities-turn-AOXNE6BM.js.map +1 -0
- package/dist/{chunk-XONQEVAD.js → chunk-DCQ7XTXW.js} +108 -3
- package/dist/chunk-DCQ7XTXW.js.map +1 -0
- package/dist/{chunk-QUX5LT2B.js → chunk-WVZQJBGD.js} +166 -45
- package/dist/chunk-WVZQJBGD.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/observability-metrics.d.ts +51 -13
- package/dist/sandbox-resume.d.ts +3 -0
- package/package.json +21 -21
- package/src/activities/agent-turn/agent-build.ts +4 -2
- package/src/activities/agent-turn/errors.ts +89 -10
- package/src/activities/agent-turn/finalization.ts +16 -0
- package/src/activities/agent-turn/governance-model.ts +8 -8
- package/src/activities/agent-turn/run.ts +12 -0
- package/src/activities/agent-turn/sandbox-establish.ts +87 -80
- package/src/activities/agent-turn/sandbox-provision.ts +51 -0
- package/src/activities/agent-turn/sandbox-runtime.ts +16 -0
- package/src/activities/agent-turn/stream-attempt.ts +36 -15
- package/src/activities/agent-turn/subscription-lease.ts +10 -1
- package/src/activities/agent-turn/tool-environment.ts +19 -0
- package/src/activities/agent-turn/tool-policy.ts +25 -0
- package/src/activities/agent-turn/turn-context.ts +8 -0
- package/src/activities/agent-turn/web-search.ts +381 -0
- package/src/activities/codemode-dispatcher.ts +57 -8
- package/src/activities/connector-attachments.ts +3 -1
- package/src/activities/sandbox-lease.ts +63 -10
- package/src/activity-services.ts +8 -0
- package/src/observability-metrics.ts +130 -17
- package/src/sandbox-resume.ts +24 -5
- package/src/sandbox-routing.ts +70 -24
- package/dist/activities-control-PEXBWKQJ.js.map +0 -1
- package/dist/activities-turn-W27A2SBO.js.map +0 -1
- package/dist/chunk-QUX5LT2B.js.map +0 -1
- package/dist/chunk-XONQEVAD.js.map +0 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type { AttemptToolDefinition } from "@opengeni/codemode";
|
|
2
|
+
import {
|
|
3
|
+
WEB_FETCH_TOOL_NAME,
|
|
4
|
+
WEB_SEARCH_TOOL_NAME,
|
|
5
|
+
webSearchCallPricing,
|
|
6
|
+
webSearchCreditMicros,
|
|
7
|
+
webSearchProviderConfig,
|
|
8
|
+
webSearchToolPlan,
|
|
9
|
+
type WebSearchProviderConfig,
|
|
10
|
+
type WebSearchProviderToolName,
|
|
11
|
+
type WebSearchSettings,
|
|
12
|
+
} from "@opengeni/config";
|
|
13
|
+
import {
|
|
14
|
+
WebSearchBillingRefusedError,
|
|
15
|
+
type WebSearchBilling,
|
|
16
|
+
type WebSearchCallCost,
|
|
17
|
+
type WebSearchCallScope,
|
|
18
|
+
} from "@opengeni/core";
|
|
19
|
+
import type { Observability } from "@opengeni/observability";
|
|
20
|
+
import {
|
|
21
|
+
WEB_FETCH_TOOL_DESCRIPTION,
|
|
22
|
+
WEB_FETCH_RETAINED_MAX_CHARS,
|
|
23
|
+
WEB_SEARCH_TOOL_DESCRIPTION,
|
|
24
|
+
WebSearchProviderError,
|
|
25
|
+
WebToolArgumentError,
|
|
26
|
+
createWebFetchProvider,
|
|
27
|
+
createWebSearchProvider,
|
|
28
|
+
parseWebFetchArguments,
|
|
29
|
+
parseWebSearchArguments,
|
|
30
|
+
renderWebPageWindow,
|
|
31
|
+
renderWebSearchResults,
|
|
32
|
+
webFetchInputSchema,
|
|
33
|
+
webSearchInputSchema,
|
|
34
|
+
type WebPage,
|
|
35
|
+
} from "@opengeni/runtime/web-search";
|
|
36
|
+
import { hostedWebSearchForTurn } from "./tool-policy";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which web search one turn gets: the provider's hosted tool, Opengeni's
|
|
40
|
+
* provider tools, or neither. Tool preparation and agent construction both
|
|
41
|
+
* call this with the same accepted model and settings, so they agree.
|
|
42
|
+
*/
|
|
43
|
+
export function turnWebSearchPlan(
|
|
44
|
+
resolvedModel: {
|
|
45
|
+
configured: { hostedWebSearch: boolean };
|
|
46
|
+
provider: { kind?: string };
|
|
47
|
+
} | null,
|
|
48
|
+
settings: WebSearchSettings,
|
|
49
|
+
) {
|
|
50
|
+
return webSearchToolPlan(settings, {
|
|
51
|
+
hostedWebSearch: hostedWebSearchForTurn(resolvedModel, settings.webSearchEnabled),
|
|
52
|
+
// SuperGrok's native search is added by its transport, not as an agent
|
|
53
|
+
// tool, so provider tools would only duplicate it.
|
|
54
|
+
transportHostedSearch: resolvedModel?.provider.kind === "xai-subscription",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Pages kept per attempt so paging through one page is not billed again. */
|
|
59
|
+
const PAGE_CACHE_ENTRIES = 16;
|
|
60
|
+
|
|
61
|
+
function textResult(text: string, isError: boolean) {
|
|
62
|
+
return { isError, content: [{ type: "text" as const, text }] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Pricing = ReturnType<typeof webSearchCallPricing>;
|
|
66
|
+
|
|
67
|
+
function settledCost(
|
|
68
|
+
operationId: string,
|
|
69
|
+
operation: "search" | "fetch",
|
|
70
|
+
provider: string,
|
|
71
|
+
pricing: Pricing,
|
|
72
|
+
reportedCostMicros: number | undefined,
|
|
73
|
+
): WebSearchCallCost {
|
|
74
|
+
// An explicit operator price wins; then the provider's own reported cost;
|
|
75
|
+
// then the built-in list price.
|
|
76
|
+
const basis: WebSearchCallCost["basis"] = pricing.explicit
|
|
77
|
+
? "configured_price"
|
|
78
|
+
: reportedCostMicros !== undefined
|
|
79
|
+
? "provider_reported"
|
|
80
|
+
: "list_price";
|
|
81
|
+
const providerMicros =
|
|
82
|
+
basis === "provider_reported" ? reportedCostMicros! : pricing.providerMicros;
|
|
83
|
+
return {
|
|
84
|
+
operationId,
|
|
85
|
+
operation,
|
|
86
|
+
provider,
|
|
87
|
+
providerMicros,
|
|
88
|
+
creditMicros: webSearchCreditMicros(providerMicros, pricing.marginBps),
|
|
89
|
+
marginBps: pricing.marginBps,
|
|
90
|
+
basis,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function providerFailure(error: WebSearchProviderError): string {
|
|
95
|
+
return `${error.message}${error.retryable ? ". You may retry shortly." : "."}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Closed outcome set for `opengeni_web_search_calls_total`. */
|
|
99
|
+
export type WebSearchCallOutcome =
|
|
100
|
+
| "ok"
|
|
101
|
+
| "provider_error"
|
|
102
|
+
| "provider_retryable"
|
|
103
|
+
| "billing_refused"
|
|
104
|
+
| "error";
|
|
105
|
+
|
|
106
|
+
type WebSearchObservability = Pick<Observability, "warn" | "incrementCounter" | "observeHistogram">;
|
|
107
|
+
|
|
108
|
+
const WEB_SEARCH_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 4, 8, 15, 30];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One provider call: a counter by fixed outcome plus its latency. Labels are
|
|
112
|
+
* the operation, the configured provider id, and the outcome only, never the
|
|
113
|
+
* query, URL, or session.
|
|
114
|
+
*/
|
|
115
|
+
function recordWebSearchCall(
|
|
116
|
+
observability: WebSearchObservability,
|
|
117
|
+
operation: "search" | "fetch",
|
|
118
|
+
provider: string,
|
|
119
|
+
outcome: WebSearchCallOutcome,
|
|
120
|
+
startedAt: number,
|
|
121
|
+
): void {
|
|
122
|
+
observability.incrementCounter({
|
|
123
|
+
name: "opengeni_web_search_calls_total",
|
|
124
|
+
help: "Provider web search/fetch calls made by the worker, by operation, provider and outcome.",
|
|
125
|
+
labels: { operation, provider, outcome },
|
|
126
|
+
});
|
|
127
|
+
observability.observeHistogram({
|
|
128
|
+
name: "opengeni_web_search_call_duration_seconds",
|
|
129
|
+
help: "Provider web search/fetch call duration in seconds.",
|
|
130
|
+
buckets: WEB_SEARCH_DURATION_BUCKETS,
|
|
131
|
+
value: (performance.now() - startedAt) / 1000,
|
|
132
|
+
labels: { operation, provider },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function providerFailureOutcome(error: WebSearchProviderError): WebSearchCallOutcome {
|
|
137
|
+
return error.retryable ? "provider_retryable" : "provider_error";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The `web_search` and `web_fetch` attempt tools for one turn. The caller
|
|
142
|
+
* decides which names the turn gets ({@link webSearchToolPlan}); this only
|
|
143
|
+
* builds them. Provider keys stay in the worker and never reach a sandbox.
|
|
144
|
+
*/
|
|
145
|
+
export function webSearchToolDefinitions(input: {
|
|
146
|
+
settings: WebSearchSettings;
|
|
147
|
+
tools: readonly WebSearchProviderToolName[];
|
|
148
|
+
scope: WebSearchCallScope;
|
|
149
|
+
billing: WebSearchBilling;
|
|
150
|
+
observability: WebSearchObservability;
|
|
151
|
+
fetch?: typeof fetch;
|
|
152
|
+
}): AttemptToolDefinition[] {
|
|
153
|
+
const config: WebSearchProviderConfig | null = webSearchProviderConfig(input.settings);
|
|
154
|
+
if (!config || input.tools.length === 0) return [];
|
|
155
|
+
const adapterInput = {
|
|
156
|
+
timeoutMs: config.timeoutMs,
|
|
157
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
158
|
+
};
|
|
159
|
+
const settle = async (cost: WebSearchCallCost) => {
|
|
160
|
+
try {
|
|
161
|
+
await input.billing.settle(input.scope, cost);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// The provider already answered. Keep the model's result; the missing
|
|
164
|
+
// receipt is an operator-visible accounting fault, never a tool failure.
|
|
165
|
+
input.observability.warn("web search usage settlement failed", {
|
|
166
|
+
operation: cost.operation,
|
|
167
|
+
provider: cost.provider,
|
|
168
|
+
error: error instanceof Error ? error.message : String(error),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const definitions: AttemptToolDefinition[] = [];
|
|
173
|
+
|
|
174
|
+
if (input.tools.includes(WEB_SEARCH_TOOL_NAME)) {
|
|
175
|
+
const provider = createWebSearchProvider({
|
|
176
|
+
...adapterInput,
|
|
177
|
+
endpoint: config.search,
|
|
178
|
+
});
|
|
179
|
+
const pricing = webSearchCallPricing(config, "search");
|
|
180
|
+
definitions.push({
|
|
181
|
+
identity: { serverId: "opengeni", toolName: WEB_SEARCH_TOOL_NAME },
|
|
182
|
+
modelName: WEB_SEARCH_TOOL_NAME,
|
|
183
|
+
codemodePath: ["opengeni", WEB_SEARCH_TOOL_NAME],
|
|
184
|
+
title: "Search the web",
|
|
185
|
+
description: WEB_SEARCH_TOOL_DESCRIPTION,
|
|
186
|
+
inputSchema: webSearchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
|
|
187
|
+
annotations: {
|
|
188
|
+
title: "Search the web",
|
|
189
|
+
readOnlyHint: true,
|
|
190
|
+
destructiveHint: false,
|
|
191
|
+
idempotentHint: true,
|
|
192
|
+
openWorldHint: true,
|
|
193
|
+
},
|
|
194
|
+
source: "opengeni",
|
|
195
|
+
approval: "none",
|
|
196
|
+
execute: async (args, context) => {
|
|
197
|
+
let request: ReturnType<typeof parseWebSearchArguments>;
|
|
198
|
+
try {
|
|
199
|
+
request = parseWebSearchArguments(args);
|
|
200
|
+
await input.billing.admit(input.scope, pricing.providerMicros);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof WebSearchBillingRefusedError) {
|
|
203
|
+
recordWebSearchCall(
|
|
204
|
+
input.observability,
|
|
205
|
+
"search",
|
|
206
|
+
config.search.provider,
|
|
207
|
+
"billing_refused",
|
|
208
|
+
performance.now(),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
error instanceof WebToolArgumentError ||
|
|
213
|
+
error instanceof WebSearchBillingRefusedError
|
|
214
|
+
)
|
|
215
|
+
return textResult(error.message, true);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
const startedAt = performance.now();
|
|
219
|
+
try {
|
|
220
|
+
const response = await provider.search(request, {
|
|
221
|
+
signal: context.signal,
|
|
222
|
+
});
|
|
223
|
+
recordWebSearchCall(
|
|
224
|
+
input.observability,
|
|
225
|
+
"search",
|
|
226
|
+
config.search.provider,
|
|
227
|
+
"ok",
|
|
228
|
+
startedAt,
|
|
229
|
+
);
|
|
230
|
+
await settle(
|
|
231
|
+
settledCost(
|
|
232
|
+
context.operationId,
|
|
233
|
+
"search",
|
|
234
|
+
config.search.provider,
|
|
235
|
+
pricing,
|
|
236
|
+
response.reportedCostMicros,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
return textResult(renderWebSearchResults(request.query, response.results), false);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (context.signal?.aborted) throw error;
|
|
242
|
+
if (error instanceof WebSearchProviderError) {
|
|
243
|
+
recordWebSearchCall(
|
|
244
|
+
input.observability,
|
|
245
|
+
"search",
|
|
246
|
+
config.search.provider,
|
|
247
|
+
providerFailureOutcome(error),
|
|
248
|
+
startedAt,
|
|
249
|
+
);
|
|
250
|
+
input.observability.warn("web search provider call failed", {
|
|
251
|
+
operation: "search",
|
|
252
|
+
provider: config.search.provider,
|
|
253
|
+
status: error.status ?? undefined,
|
|
254
|
+
retryable: error.retryable,
|
|
255
|
+
error: error.message,
|
|
256
|
+
});
|
|
257
|
+
return textResult(`Web search failed: ${providerFailure(error)}`, true);
|
|
258
|
+
}
|
|
259
|
+
recordWebSearchCall(
|
|
260
|
+
input.observability,
|
|
261
|
+
"search",
|
|
262
|
+
config.search.provider,
|
|
263
|
+
"error",
|
|
264
|
+
startedAt,
|
|
265
|
+
);
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (input.tools.includes(WEB_FETCH_TOOL_NAME) && config.fetch) {
|
|
273
|
+
const fetchEndpoint = config.fetch;
|
|
274
|
+
const provider = createWebFetchProvider({
|
|
275
|
+
...adapterInput,
|
|
276
|
+
endpoint: fetchEndpoint,
|
|
277
|
+
});
|
|
278
|
+
const pricing = webSearchCallPricing(config, "fetch");
|
|
279
|
+
const pages = new Map<string, WebPage>();
|
|
280
|
+
definitions.push({
|
|
281
|
+
identity: { serverId: "opengeni", toolName: WEB_FETCH_TOOL_NAME },
|
|
282
|
+
modelName: WEB_FETCH_TOOL_NAME,
|
|
283
|
+
codemodePath: ["opengeni", WEB_FETCH_TOOL_NAME],
|
|
284
|
+
title: "Read a web page",
|
|
285
|
+
description: WEB_FETCH_TOOL_DESCRIPTION,
|
|
286
|
+
inputSchema: webFetchInputSchema as unknown as AttemptToolDefinition["inputSchema"],
|
|
287
|
+
annotations: {
|
|
288
|
+
title: "Read a web page",
|
|
289
|
+
readOnlyHint: true,
|
|
290
|
+
destructiveHint: false,
|
|
291
|
+
idempotentHint: true,
|
|
292
|
+
openWorldHint: true,
|
|
293
|
+
},
|
|
294
|
+
source: "opengeni",
|
|
295
|
+
approval: "none",
|
|
296
|
+
execute: async (args, context) => {
|
|
297
|
+
let request: ReturnType<typeof parseWebFetchArguments>;
|
|
298
|
+
try {
|
|
299
|
+
request = parseWebFetchArguments(args);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof WebToolArgumentError) return textResult(error.message, true);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
const cached = pages.get(request.url);
|
|
305
|
+
if (cached) return textResult(renderWebPageWindow(cached, request), false);
|
|
306
|
+
try {
|
|
307
|
+
await input.billing.admit(input.scope, pricing.providerMicros);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof WebSearchBillingRefusedError) {
|
|
310
|
+
recordWebSearchCall(
|
|
311
|
+
input.observability,
|
|
312
|
+
"fetch",
|
|
313
|
+
fetchEndpoint.provider,
|
|
314
|
+
"billing_refused",
|
|
315
|
+
performance.now(),
|
|
316
|
+
);
|
|
317
|
+
return textResult(error.message, true);
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
const startedAt = performance.now();
|
|
322
|
+
try {
|
|
323
|
+
const page = await provider.fetch({ url: request.url }, { signal: context.signal });
|
|
324
|
+
recordWebSearchCall(
|
|
325
|
+
input.observability,
|
|
326
|
+
"fetch",
|
|
327
|
+
fetchEndpoint.provider,
|
|
328
|
+
"ok",
|
|
329
|
+
startedAt,
|
|
330
|
+
);
|
|
331
|
+
await settle(
|
|
332
|
+
settledCost(
|
|
333
|
+
context.operationId,
|
|
334
|
+
"fetch",
|
|
335
|
+
fetchEndpoint.provider,
|
|
336
|
+
pricing,
|
|
337
|
+
page.reportedCostMicros,
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
if (pages.size >= PAGE_CACHE_ENTRIES) {
|
|
341
|
+
pages.delete(pages.keys().next().value!);
|
|
342
|
+
}
|
|
343
|
+
// Bound per-attempt memory; one extra character keeps the cut visible.
|
|
344
|
+
pages.set(request.url, {
|
|
345
|
+
...page,
|
|
346
|
+
content: page.content.slice(0, WEB_FETCH_RETAINED_MAX_CHARS + 1),
|
|
347
|
+
});
|
|
348
|
+
return textResult(renderWebPageWindow(page, request), false);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (context.signal?.aborted) throw error;
|
|
351
|
+
if (error instanceof WebSearchProviderError) {
|
|
352
|
+
recordWebSearchCall(
|
|
353
|
+
input.observability,
|
|
354
|
+
"fetch",
|
|
355
|
+
fetchEndpoint.provider,
|
|
356
|
+
providerFailureOutcome(error),
|
|
357
|
+
startedAt,
|
|
358
|
+
);
|
|
359
|
+
input.observability.warn("web search provider call failed", {
|
|
360
|
+
operation: "fetch",
|
|
361
|
+
provider: fetchEndpoint.provider,
|
|
362
|
+
status: error.status ?? undefined,
|
|
363
|
+
retryable: error.retryable,
|
|
364
|
+
error: error.message,
|
|
365
|
+
});
|
|
366
|
+
return textResult(`Web fetch failed: ${providerFailure(error)}`, true);
|
|
367
|
+
}
|
|
368
|
+
recordWebSearchCall(
|
|
369
|
+
input.observability,
|
|
370
|
+
"fetch",
|
|
371
|
+
fetchEndpoint.provider,
|
|
372
|
+
"error",
|
|
373
|
+
startedAt,
|
|
374
|
+
);
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return definitions;
|
|
381
|
+
}
|
|
@@ -61,6 +61,12 @@ export type CodemodeDispatcherTimings = {
|
|
|
61
61
|
claimHeartbeatMs?: number;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
const RESUMABLE_STATES: readonly CodemodeOperation["state"][] = [
|
|
65
|
+
"waiting_for_approval",
|
|
66
|
+
"queued",
|
|
67
|
+
"running",
|
|
68
|
+
];
|
|
69
|
+
|
|
64
70
|
/**
|
|
65
71
|
* Owns the only Codemode execution edge for one exact attempt. NATS carries a
|
|
66
72
|
* wake-up only; the durable row is claimed before any side effect and the call
|
|
@@ -190,11 +196,20 @@ export class CodemodeAttemptDispatcher {
|
|
|
190
196
|
);
|
|
191
197
|
}
|
|
192
198
|
|
|
193
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Join-on-use recovery before the first model request. The journal read needs
|
|
201
|
+
* no tool environment, so a turn with nothing unfinished returns at once and
|
|
202
|
+
* never waits on lazy MCP preparation. Only a stored waiting/queued/running
|
|
203
|
+
* operation awaits the prepared dispatcher to adopt and execute it.
|
|
204
|
+
*/
|
|
205
|
+
static async resumeApproved(
|
|
206
|
+
db: Database,
|
|
207
|
+
scope: CodemodeDispatcherScope,
|
|
194
208
|
callerSubjectId: string,
|
|
195
|
-
decisionOperationId
|
|
209
|
+
decisionOperationId: string | undefined,
|
|
210
|
+
prepared: () => Promise<CodemodeAttemptDispatcher | null>,
|
|
196
211
|
): Promise<CodemodeOperation[]> {
|
|
197
|
-
const operations = await listTurnCodemodeApprovals(
|
|
212
|
+
const operations = await listTurnCodemodeApprovals(db, scope);
|
|
198
213
|
if (
|
|
199
214
|
decisionOperationId &&
|
|
200
215
|
// Model-tool approval IDs are opaque strings. Only programmatic UUIDs
|
|
@@ -202,15 +217,38 @@ export class CodemodeAttemptDispatcher {
|
|
|
202
217
|
CodemodeOperation.shape.operationId.safeParse(decisionOperationId).success &&
|
|
203
218
|
!operations.some((operation) => operation.operationId === decisionOperationId)
|
|
204
219
|
) {
|
|
205
|
-
const decided = await readTurnCodemodeOperation(
|
|
206
|
-
...
|
|
220
|
+
const decided = await readTurnCodemodeOperation(db, {
|
|
221
|
+
...scope,
|
|
207
222
|
callerSubjectId,
|
|
208
223
|
operationId: decisionOperationId,
|
|
209
224
|
});
|
|
210
225
|
if (decided) operations.push(decided);
|
|
211
226
|
}
|
|
227
|
+
if (!operations.some((operation) => RESUMABLE_STATES.includes(operation.state))) {
|
|
228
|
+
return operations;
|
|
229
|
+
}
|
|
230
|
+
return (await (await prepared())?.resumeOperations(operations, callerSubjectId)) ?? [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async resumeApproved(
|
|
234
|
+
callerSubjectId: string,
|
|
235
|
+
decisionOperationId?: string,
|
|
236
|
+
): Promise<CodemodeOperation[]> {
|
|
237
|
+
return await CodemodeAttemptDispatcher.resumeApproved(
|
|
238
|
+
this.db,
|
|
239
|
+
this.scope,
|
|
240
|
+
callerSubjectId,
|
|
241
|
+
decisionOperationId,
|
|
242
|
+
async () => this,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async resumeOperations(
|
|
247
|
+
operations: CodemodeOperation[],
|
|
248
|
+
callerSubjectId: string,
|
|
249
|
+
): Promise<CodemodeOperation[]> {
|
|
212
250
|
for (const operation of operations) {
|
|
213
|
-
if (!
|
|
251
|
+
if (!RESUMABLE_STATES.includes(operation.state)) continue;
|
|
214
252
|
let effectDigest: string | null = null;
|
|
215
253
|
try {
|
|
216
254
|
effectDigest = this.environment.effectDigest(operation.identity);
|
|
@@ -380,7 +418,17 @@ export class CodemodeAttemptDispatcher {
|
|
|
380
418
|
try {
|
|
381
419
|
if (operation.durableApproval) {
|
|
382
420
|
if (!this.approvalWait) throw new Error("Durable approval yield is unavailable");
|
|
383
|
-
|
|
421
|
+
// The wait gate is sealed while a model request is in flight or the
|
|
422
|
+
// stream is settling. A call arriving then cannot durably wait, but it
|
|
423
|
+
// must not fail outright: prepare it like a non-waiting client, so an
|
|
424
|
+
// allowed call still executes and an Ask settles approval_required.
|
|
425
|
+
try {
|
|
426
|
+
releaseWait = this.approvalWait.beginWait();
|
|
427
|
+
} catch {
|
|
428
|
+
releaseWait = undefined;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (releaseWait) {
|
|
384
432
|
if (
|
|
385
433
|
!(await bindCodemodePreparation(this.db, {
|
|
386
434
|
...this.scope,
|
|
@@ -407,10 +455,11 @@ export class CodemodeAttemptDispatcher {
|
|
|
407
455
|
},
|
|
408
456
|
{
|
|
409
457
|
signal,
|
|
410
|
-
...(
|
|
458
|
+
...(releaseWait ? { transportMeta: { durableApproval: true } } : {}),
|
|
411
459
|
},
|
|
412
460
|
);
|
|
413
461
|
if (preparedCall.waitingForApproval) {
|
|
462
|
+
if (!releaseWait) throw new AttemptToolApprovalRequiredError();
|
|
414
463
|
const waiting = await waitForCodemodeApproval(this.db, {
|
|
415
464
|
...this.scope,
|
|
416
465
|
operationId: operation.operationId,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
ChannelAPartialMutationError,
|
|
14
14
|
RoutingMutationOutcomeUnknownError,
|
|
15
|
+
isRoutingMutationOutputRejectedError,
|
|
15
16
|
SandboxChannelAService,
|
|
16
17
|
} from "@opengeni/runtime/sandbox";
|
|
17
18
|
|
|
@@ -159,7 +160,8 @@ export async function materializeConnectorAttachmentsInChannel(
|
|
|
159
160
|
} catch (error) {
|
|
160
161
|
if (
|
|
161
162
|
error instanceof ChannelAPartialMutationError ||
|
|
162
|
-
error instanceof RoutingMutationOutcomeUnknownError
|
|
163
|
+
error instanceof RoutingMutationOutcomeUnknownError ||
|
|
164
|
+
isRoutingMutationOutputRejectedError(error)
|
|
163
165
|
) {
|
|
164
166
|
throw error;
|
|
165
167
|
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
recordRecoveredModalProviderCreate,
|
|
54
54
|
markWarmBillingStopCutoff,
|
|
55
55
|
listMeterableWarmLeases,
|
|
56
|
+
countInteractionOnlyWarmLeasesByIdle,
|
|
56
57
|
listSandboxViewerForceDrainWorkspaceIds,
|
|
57
58
|
markSandboxCheckpointArtifactDeletePending,
|
|
58
59
|
persistDrainSnapshot,
|
|
@@ -174,6 +175,8 @@ import {
|
|
|
174
175
|
type RetainedProcessReconciliationOutcome,
|
|
175
176
|
type SandboxInventoryProjectionDomain,
|
|
176
177
|
recordSandboxLeaseGauges,
|
|
178
|
+
recordModalSandboxInventoryGauges,
|
|
179
|
+
recordInteractionOnlyLeaseGauges,
|
|
177
180
|
recordSandboxOrphansTerminated,
|
|
178
181
|
recordSandboxCommandContainment,
|
|
179
182
|
recordSandboxProviderMissingBeforeCapture,
|
|
@@ -2688,6 +2691,17 @@ async function refreshQueueLeaseAndCreditGauges(
|
|
|
2688
2691
|
);
|
|
2689
2692
|
},
|
|
2690
2693
|
),
|
|
2694
|
+
refreshSandboxInventoryGauge(
|
|
2695
|
+
observability,
|
|
2696
|
+
"interaction_idle",
|
|
2697
|
+
"interaction-only-lease",
|
|
2698
|
+
async () => {
|
|
2699
|
+
recordInteractionOnlyLeaseGauges(
|
|
2700
|
+
observability,
|
|
2701
|
+
await countInteractionOnlyWarmLeasesByIdle(db, await listMeterableWarmLeases(db)),
|
|
2702
|
+
);
|
|
2703
|
+
},
|
|
2704
|
+
),
|
|
2691
2705
|
refreshSandboxInventoryGauge(observability, "expired_drains", "expired-draining", async () => {
|
|
2692
2706
|
recordExpiredDrainingSandboxLeaseGauges(
|
|
2693
2707
|
observability,
|
|
@@ -2876,6 +2890,16 @@ export function modalOrphanTerminationStillEligible(
|
|
|
2876
2890
|
if (latest.some((lease) => lease.liveness === "warming" && lease.instanceId === null)) {
|
|
2877
2891
|
return false;
|
|
2878
2892
|
}
|
|
2893
|
+
return modalOrphanCandidateStillUnowned(latest, candidate);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/** Whether no live lease owns the candidate box by exact instance or by its
|
|
2897
|
+
* attribution tags. Unlike termination eligibility this ignores the pending
|
|
2898
|
+
* create postponement: a postponed orphan is still running without a lease. */
|
|
2899
|
+
export function modalOrphanCandidateStillUnowned(
|
|
2900
|
+
latest: Awaited<ReturnType<typeof listLiveModalSandboxLeaseAttributions>>,
|
|
2901
|
+
candidate: ModalOrphanSweepTermination,
|
|
2902
|
+
): boolean {
|
|
2879
2903
|
if (latest.some((lease) => lease.instanceId === candidate.sandboxId)) {
|
|
2880
2904
|
return false;
|
|
2881
2905
|
}
|
|
@@ -2925,6 +2949,30 @@ async function sweepModalOrphansForConfiguredBackend(
|
|
|
2925
2949
|
return modalOrphanTerminationStillEligible(latest, candidate);
|
|
2926
2950
|
},
|
|
2927
2951
|
});
|
|
2952
|
+
// Only a complete listing is an inventory; a pass cut short by the
|
|
2953
|
+
// termination budget leaves the previous projection to age out as stale.
|
|
2954
|
+
// Re-read durable ownership after the listing so a lease that started or
|
|
2955
|
+
// finished while the app was being listed is not reported as a leak.
|
|
2956
|
+
if (result.inventory.complete) {
|
|
2957
|
+
try {
|
|
2958
|
+
const latest = await listLiveModalSandboxLeaseAttributions(db);
|
|
2959
|
+
recordModalSandboxInventoryGauges(observability, {
|
|
2960
|
+
running: result.inventory.running,
|
|
2961
|
+
unleased: result.inventory.unterminated.filter((candidate) =>
|
|
2962
|
+
modalOrphanCandidateStillUnowned(latest, candidate),
|
|
2963
|
+
).length,
|
|
2964
|
+
liveLeaseInstancesMissing: result.inventory.missingLiveLeaseInstanceIds.filter(
|
|
2965
|
+
(instanceId) => latest.some((lease) => lease.instanceId === instanceId),
|
|
2966
|
+
).length,
|
|
2967
|
+
});
|
|
2968
|
+
recordSandboxInventoryProjectionSuccess(observability, "modal_provider");
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
recordSandboxInventoryProjectionFailure(observability, "modal_provider");
|
|
2971
|
+
observability.warn("sandbox reaper: Modal inventory ownership re-read failed", {
|
|
2972
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2928
2976
|
for (const terminated of result.terminated) {
|
|
2929
2977
|
observability.warn("sandbox reaper: terminated Modal orphan sandbox", {
|
|
2930
2978
|
sandboxId: terminated.sandboxId,
|
|
@@ -3524,15 +3572,18 @@ async function terminateDrainableBox(
|
|
|
3524
3572
|
// with draining->cold. Until this succeeds, arrivals remain fenced by that
|
|
3525
3573
|
// exact claim; a timestamp or a failed provider call can never reopen a box
|
|
3526
3574
|
// while termination may still be in flight.
|
|
3527
|
-
const { wentCold, backgroundCommandEvents } = await confirmDrainCold(
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3575
|
+
const { wentCold, unpublishedProviderLost, backgroundCommandEvents } = await confirmDrainCold(
|
|
3576
|
+
db,
|
|
3577
|
+
{
|
|
3578
|
+
accountId,
|
|
3579
|
+
workspaceId: row.workspaceId,
|
|
3580
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3581
|
+
expectedEpoch: row.leaseEpoch,
|
|
3582
|
+
...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
|
|
3583
|
+
providerMissingBeforeCapture: providerMissing,
|
|
3584
|
+
idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
|
|
3585
|
+
},
|
|
3586
|
+
);
|
|
3536
3587
|
// The command terminal events and agent inputs are already durable in the
|
|
3537
3588
|
// cold commit; this is only best-effort live fanout.
|
|
3538
3589
|
await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
|
|
@@ -3545,7 +3596,9 @@ async function terminateDrainableBox(
|
|
|
3545
3596
|
// Only the exact successful cold commit counts provider loss. A missing
|
|
3546
3597
|
// probe, a stale capture, a failed commit, or a retried child is not another
|
|
3547
3598
|
// observed loss. Keep this outside the best-effort session event writer.
|
|
3548
|
-
|
|
3599
|
+
// An unpublished warming replacement (its creator died before warm
|
|
3600
|
+
// publication) held no workspace; its absence is not a capture loss.
|
|
3601
|
+
if (providerMissing && !unpublishedProviderLost) {
|
|
3549
3602
|
recordSandboxProviderMissingBeforeCapture(observability, backend);
|
|
3550
3603
|
}
|
|
3551
3604
|
if (lease.unobservableCommandDrainIds?.length) {
|
package/src/activity-services.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
codeSearchDeploymentPolicy,
|
|
3
|
+
resolveWebSearchProvider,
|
|
3
4
|
dbSearchPath,
|
|
4
5
|
getSettings,
|
|
5
6
|
resolveNatsControlPlaneAuth,
|
|
@@ -52,6 +53,13 @@ export function createSharedActivityServices(
|
|
|
52
53
|
// Child, scheduled and automation sessions created here freeze their
|
|
53
54
|
// code_search decision from the same deployment policy as the API.
|
|
54
55
|
configureCodeSearchDeploymentPolicy(codeSearchDeploymentPolicy(settings));
|
|
56
|
+
// A named but unusable web search provider withholds the tools; say why.
|
|
57
|
+
const webSearchProvider = resolveWebSearchProvider(settings);
|
|
58
|
+
if (webSearchProvider.status === "invalid") {
|
|
59
|
+
observability.warn("web search provider is misconfigured; web_search stays off", {
|
|
60
|
+
reason: webSearchProvider.reason,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
55
63
|
return {
|
|
56
64
|
settings: resolvedSettings,
|
|
57
65
|
catalogSourceSettings: settings,
|