@opengeni/worker-bundle 2.0.3 → 2.1.0-canary.36239117573001
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/agent-build.d.ts +2 -0
- package/dist/activities/agent-turn/code-search.d.ts +60 -0
- package/dist/activities/agent-turn/codex-capacity.d.ts +13 -0
- package/dist/activities/agent-turn/errors.d.ts +12 -1
- package/dist/activities/agent-turn/governance-model.d.ts +2 -0
- package/dist/activities/agent-turn/session-title.d.ts +32 -2
- package/dist/activities/agent-turn/tool-environment.d.ts +4 -0
- package/dist/activities/context-compaction.d.ts +10 -2
- package/dist/activities/knowledge-indexing.d.ts +19 -0
- package/dist/{activities-control-IVTL723D.js → activities-control-I72CRMAE.js} +81 -14
- package/dist/activities-control-I72CRMAE.js.map +1 -0
- package/dist/{activities-turn-EZEDZTEZ.js → activities-turn-CSDKK4N3.js} +425 -46
- package/dist/activities-turn-CSDKK4N3.js.map +1 -0
- package/dist/artifact-outbox-entry.js +3 -0
- package/dist/artifact-outbox-entry.js.map +1 -1
- package/dist/{chunk-JMO5ZUW3.js → chunk-7M6G2SCF.js} +159 -11
- package/dist/chunk-7M6G2SCF.js.map +1 -0
- package/dist/{chunk-I7HKKMUJ.js → chunk-DSPP6CZL.js} +48 -3
- package/dist/chunk-DSPP6CZL.js.map +1 -0
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/observability-metrics.d.ts +11 -1
- package/dist/sandbox-resume.d.ts +46 -0
- package/dist/workflow-bundle.js +77 -2
- package/dist/workflows/session.d.ts +30 -0
- package/package.json +20 -19
- package/src/activities/agent-turn/agent-build.ts +4 -0
- package/src/activities/agent-turn/code-search.ts +275 -0
- package/src/activities/agent-turn/codex-capacity.ts +37 -2
- package/src/activities/agent-turn/compaction-prep.ts +52 -16
- package/src/activities/agent-turn/errors.ts +59 -1
- package/src/activities/agent-turn/governance-model.ts +17 -1
- package/src/activities/agent-turn/run.ts +4 -0
- package/src/activities/agent-turn/sandbox-establish.ts +12 -1
- package/src/activities/agent-turn/session-title.ts +73 -3
- package/src/activities/agent-turn/stream-attempt.ts +14 -13
- package/src/activities/agent-turn/tool-environment.ts +65 -0
- package/src/activities/context-compaction.ts +49 -14
- package/src/activities/knowledge-indexing.ts +107 -9
- package/src/activities/scheduled-tasks.ts +18 -2
- package/src/activity-services.ts +16 -4
- package/src/editable-artifact-hint-broker.ts +3 -0
- package/src/index.ts +2 -2
- package/src/observability-metrics.ts +70 -1
- package/src/personal-github-git-credentials.ts +2 -0
- package/src/sandbox-resume.ts +205 -5
- package/src/workflows/session.ts +102 -6
- package/dist/activities-control-IVTL723D.js.map +0 -1
- package/dist/activities-turn-EZEDZTEZ.js.map +0 -1
- package/dist/chunk-I7HKKMUJ.js.map +0 -1
- package/dist/chunk-JMO5ZUW3.js.map +0 -1
|
@@ -20,6 +20,95 @@ import {
|
|
|
20
20
|
import type { DocumentServices } from "@opengeni/documents";
|
|
21
21
|
import type { ControlActivityServices } from "./types";
|
|
22
22
|
|
|
23
|
+
/** The configured monthly indexed-chunk limit, not a provider failure. */
|
|
24
|
+
export class KnowledgeIndexUsageLimitError extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super("monthly document indexing limit reached");
|
|
27
|
+
this.name = "KnowledgeIndexUsageLimitError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type KnowledgeIndexFailureStage = "embedding" | "processing";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Content-free classification for a deferred Knowledge index batch. Only
|
|
35
|
+
* protocol constants, an HTTP status, and a PostgreSQL SQLSTATE are retained;
|
|
36
|
+
* provider messages, bodies, SQL, and identifiers never leave the process.
|
|
37
|
+
* Outside the embedding call, only a PostgreSQL error in the cause chain is
|
|
38
|
+
* attributed to the database; any other failure stays a worker failure.
|
|
39
|
+
*/
|
|
40
|
+
export function knowledgeIndexFailureDiagnostic(
|
|
41
|
+
stage: KnowledgeIndexFailureStage,
|
|
42
|
+
error: unknown,
|
|
43
|
+
): {
|
|
44
|
+
errorClass: "KnowledgeIndexOperationError";
|
|
45
|
+
errorCode:
|
|
46
|
+
| "knowledge_index_usage_limit_reached"
|
|
47
|
+
| "knowledge_index_embedding_failed"
|
|
48
|
+
| "knowledge_index_persistence_failed"
|
|
49
|
+
| "knowledge_index_failed";
|
|
50
|
+
origin: "worker" | "db";
|
|
51
|
+
status?: number;
|
|
52
|
+
sqlState?: string;
|
|
53
|
+
} {
|
|
54
|
+
if (error instanceof KnowledgeIndexUsageLimitError) {
|
|
55
|
+
return {
|
|
56
|
+
errorClass: "KnowledgeIndexOperationError",
|
|
57
|
+
errorCode: "knowledge_index_usage_limit_reached",
|
|
58
|
+
origin: "worker",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (stage === "embedding") {
|
|
62
|
+
const status = ownValue(error, "status");
|
|
63
|
+
return {
|
|
64
|
+
errorClass: "KnowledgeIndexOperationError",
|
|
65
|
+
errorCode: "knowledge_index_embedding_failed",
|
|
66
|
+
origin: "worker",
|
|
67
|
+
...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599
|
|
68
|
+
? { status }
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const postgres = postgresErrorState(error);
|
|
73
|
+
if (!postgres) {
|
|
74
|
+
return {
|
|
75
|
+
errorClass: "KnowledgeIndexOperationError",
|
|
76
|
+
errorCode: "knowledge_index_failed",
|
|
77
|
+
origin: "worker",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
errorClass: "KnowledgeIndexOperationError",
|
|
82
|
+
errorCode: "knowledge_index_persistence_failed",
|
|
83
|
+
origin: "db",
|
|
84
|
+
...(postgres.sqlState ? { sqlState: postgres.sqlState } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ownValue(value: unknown, key: string): unknown {
|
|
89
|
+
try {
|
|
90
|
+
if (!value || typeof value !== "object") return undefined;
|
|
91
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
92
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The nearest PostgreSQL error in a short cause chain, with its SQLSTATE
|
|
99
|
+
* when that is a well-formed five-character code. */
|
|
100
|
+
function postgresErrorState(error: unknown): { sqlState?: string } | undefined {
|
|
101
|
+
let current = error;
|
|
102
|
+
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
|
|
103
|
+
if (ownValue(current, "name") === "PostgresError") {
|
|
104
|
+
const code = ownValue(current, "code");
|
|
105
|
+
return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code) ? { sqlState: code } : {};
|
|
106
|
+
}
|
|
107
|
+
current = ownValue(current, "cause");
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
23
112
|
export function createKnowledgeIndexingActivities(
|
|
24
113
|
services: () => Promise<ControlActivityServices>,
|
|
25
114
|
resolveDocumentServices?: () => Promise<DocumentServices>,
|
|
@@ -52,6 +141,7 @@ export function createKnowledgeIndexingActivities(
|
|
|
52
141
|
limit: 2,
|
|
53
142
|
});
|
|
54
143
|
for (const claim of claims) {
|
|
144
|
+
let stage: KnowledgeIndexFailureStage = "processing";
|
|
55
145
|
try {
|
|
56
146
|
const source = await readKnowledgeIndexSource(db, claim);
|
|
57
147
|
if (!source) {
|
|
@@ -126,8 +216,7 @@ export function createKnowledgeIndexingActivities(
|
|
|
126
216
|
eventType: "document.indexed",
|
|
127
217
|
since: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
|
128
218
|
});
|
|
129
|
-
if (used + chunks.length > limit)
|
|
130
|
-
throw new Error("monthly document indexing limit reached");
|
|
219
|
+
if (used + chunks.length > limit) throw new KnowledgeIndexUsageLimitError();
|
|
131
220
|
}
|
|
132
221
|
}
|
|
133
222
|
const inputs = chunks.map((chunk) => chunk.embeddingInput);
|
|
@@ -135,9 +224,11 @@ export function createKnowledgeIndexingActivities(
|
|
|
135
224
|
(sum, input) => sum + Buffer.byteLength(input, "utf8"),
|
|
136
225
|
0,
|
|
137
226
|
);
|
|
227
|
+
stage = "embedding";
|
|
138
228
|
const vectors = await embedder.embedMany(inputs);
|
|
139
229
|
if (vectors.length !== chunks.length)
|
|
140
230
|
throw new Error("Incomplete Knowledge embeddings");
|
|
231
|
+
stage = "processing";
|
|
141
232
|
// A reviewer may have rejected this revision during the provider
|
|
142
233
|
// call. The DB guard holds its publication row through settlement.
|
|
143
234
|
if (paid) {
|
|
@@ -239,16 +330,23 @@ export function createKnowledgeIndexingActivities(
|
|
|
239
330
|
else result.unavailable++;
|
|
240
331
|
}
|
|
241
332
|
});
|
|
242
|
-
} catch {
|
|
333
|
+
} catch (error) {
|
|
243
334
|
// Provider failures retain the last completed projection. The durable
|
|
244
335
|
// queue owns retry/backoff; do not retry an entire activity implicitly.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
observability.warn(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
336
|
+
// The stored reason stays the SQL lifecycle's fixed code; the log
|
|
337
|
+
// carries the content-free class/code of the actual cause.
|
|
338
|
+
observability.warn(
|
|
339
|
+
"Knowledge indexing batch deferred",
|
|
340
|
+
knowledgeIndexFailureDiagnostic(stage, error),
|
|
341
|
+
);
|
|
342
|
+
await deferKnowledgeIndexJob(db, claim).catch((deferError: unknown) => {
|
|
343
|
+
const deferDiagnostic = knowledgeIndexFailureDiagnostic("processing", deferError);
|
|
344
|
+
observability.warn("Knowledge indexing batch deferral failed", {
|
|
345
|
+
...deferDiagnostic,
|
|
346
|
+
errorCode: "knowledge_index_defer_failed",
|
|
347
|
+
});
|
|
251
348
|
});
|
|
349
|
+
result.deferred++;
|
|
252
350
|
}
|
|
253
351
|
}
|
|
254
352
|
return result;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
openGeniSlackBotMetadata,
|
|
21
21
|
requireOpenGeniSlackBotConnection,
|
|
22
22
|
resolveWorkspaceCatalogSettings,
|
|
23
|
+
resolveScheduledTaskDefaultModel,
|
|
23
24
|
resolveSessionToolPolicy,
|
|
24
25
|
workspaceCustomModelReference,
|
|
25
26
|
lockActiveCustomModelForAdmission,
|
|
@@ -438,8 +439,23 @@ export function createScheduledTaskActivities(services: () => Promise<ControlAct
|
|
|
438
439
|
)
|
|
439
440
|
: null;
|
|
440
441
|
const settings = await settingsForTask(task, targetSessionExecutionBase?.model);
|
|
441
|
-
|
|
442
|
-
|
|
442
|
+
// A task that names no model resolves the new-chat default at each fresh
|
|
443
|
+
// occurrence (saved workspace default, then a usable connected
|
|
444
|
+
// subscription under the task's frozen SuperGrok authority, then the
|
|
445
|
+
// credits default while the organization holds a credit balance, then the
|
|
446
|
+
// deployment default). A frozen SuperGrok pool that is gone only means
|
|
447
|
+
// SuperGrok is not ready. The accepted execution freezes the result, so
|
|
448
|
+
// retries and recovery never resolve again. Existing-session runs keep
|
|
449
|
+
// that session's model.
|
|
450
|
+
const resolvedDefault =
|
|
451
|
+
task.agentConfig.model || targetSessionExecutionBase
|
|
452
|
+
? null
|
|
453
|
+
: await resolveScheduledTaskDefaultModel(db, settings, task);
|
|
454
|
+
const model = task.agentConfig.model ?? resolvedDefault?.model ?? settings.openaiModel;
|
|
455
|
+
const reasoningEffort =
|
|
456
|
+
task.agentConfig.reasoningEffort ??
|
|
457
|
+
resolvedDefault?.reasoningEffort ??
|
|
458
|
+
settings.openaiReasoningEffort;
|
|
443
459
|
let sandboxBackend = task.agentConfig.sandboxBackend ?? settings.sandboxBackend;
|
|
444
460
|
let sandboxOs: "linux" | "macos" | "windows" = "linux";
|
|
445
461
|
const taskTools = withFirstPartyTools(settings, task.agentConfig.tools);
|
package/src/activity-services.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
codeSearchDeploymentPolicy,
|
|
3
|
+
dbSearchPath,
|
|
4
|
+
getSettings,
|
|
5
|
+
resolveNatsControlPlaneAuth,
|
|
6
|
+
} from "@opengeni/config";
|
|
7
|
+
import {
|
|
8
|
+
configureChildLifecycleNotices,
|
|
9
|
+
configureCodeSearchDeploymentPolicy,
|
|
10
|
+
createDb,
|
|
11
|
+
} from "@opengeni/db";
|
|
3
12
|
import { createNatsEventBus } from "@opengeni/events";
|
|
4
13
|
import { createObservability } from "@opengeni/observability";
|
|
5
14
|
import { resolveCatalogSettings } from "@opengeni/core";
|
|
6
15
|
import { createObjectStorage } from "@opengeni/storage";
|
|
7
16
|
import type { ActivityDependencies, SharedActivityServices } from "./activities/types";
|
|
8
|
-
import {
|
|
17
|
+
import { observabilityEventBusOptions } from "./observability-metrics";
|
|
9
18
|
import { buildPersonalGitHubGitCredentials } from "./personal-github-git-credentials";
|
|
10
19
|
import { createStandaloneConnectionCredentialsPort } from "./pr-review-credentials";
|
|
11
20
|
|
|
@@ -40,6 +49,9 @@ export function createSharedActivityServices(
|
|
|
40
49
|
// Child lifecycle notice producers are process-global in @opengeni/db;
|
|
41
50
|
// install the boot-validated rollout flag once for this worker.
|
|
42
51
|
configureChildLifecycleNotices({ enabled: settings.childLifecycleNoticesEnabled });
|
|
52
|
+
// Child, scheduled and automation sessions created here freeze their
|
|
53
|
+
// code_search decision from the same deployment policy as the API.
|
|
54
|
+
configureCodeSearchDeploymentPolicy(codeSearchDeploymentPolicy(settings));
|
|
43
55
|
return {
|
|
44
56
|
settings: resolvedSettings,
|
|
45
57
|
catalogSourceSettings: settings,
|
|
@@ -51,7 +63,7 @@ export function createSharedActivityServices(
|
|
|
51
63
|
controlPlaneAuth
|
|
52
64
|
? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password }
|
|
53
65
|
: undefined,
|
|
54
|
-
|
|
66
|
+
observabilityEventBusOptions(observability),
|
|
55
67
|
)),
|
|
56
68
|
objectStorage: dependencies.objectStorage ?? createObjectStorage(settings),
|
|
57
69
|
observability,
|
|
@@ -161,6 +161,9 @@ export async function connectEditableArtifactHintBroker(
|
|
|
161
161
|
name,
|
|
162
162
|
reconnect: true,
|
|
163
163
|
maxReconnectAttempts: -1,
|
|
164
|
+
// Same policy as the event bus: a broker or auth-callout restart must not
|
|
165
|
+
// permanently close this connection after two identical auth errors.
|
|
166
|
+
ignoreAuthErrorAbort: true,
|
|
164
167
|
reconnectTimeWait: 2_000,
|
|
165
168
|
reconnectJitter: 1_000,
|
|
166
169
|
reconnectJitterTLS: 1_000,
|
package/src/index.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
initializeContextCompactionMetrics,
|
|
62
62
|
initializeWorkerOutcomeMetrics,
|
|
63
63
|
normalizeTurnTaskQueueStats,
|
|
64
|
-
|
|
64
|
+
observabilityEventBusOptions,
|
|
65
65
|
startContextCompactionPendingMonitor,
|
|
66
66
|
startSessionRecoveryMonitor,
|
|
67
67
|
startTurnCapacityMonitor,
|
|
@@ -1173,7 +1173,7 @@ export async function startWorker() {
|
|
|
1173
1173
|
controlPlaneAuth
|
|
1174
1174
|
? { user: controlPlaneAuth.user, pass: controlPlaneAuth.password }
|
|
1175
1175
|
: undefined,
|
|
1176
|
-
|
|
1176
|
+
observabilityEventBusOptions(observability),
|
|
1177
1177
|
),
|
|
1178
1178
|
{ ...retryOptions, onRetry },
|
|
1179
1179
|
);
|
|
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { errorCodeToJSON } from "@opengeni/agent-proto";
|
|
3
3
|
import { SandboxBackend, type SessionEventType } from "@opengeni/contracts";
|
|
4
4
|
import type { SessionEventAppendPhaseObservation } from "@opengeni/db";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
natsSubscriptionTerminationCounter,
|
|
7
|
+
type EventBusOptions,
|
|
8
|
+
type EventLogger,
|
|
9
|
+
} from "@opengeni/events";
|
|
6
10
|
import type { Attributes, AttributeValue, Observability } from "@opengeni/observability";
|
|
7
11
|
import type { CompanyBrainContributionReceipt } from "./model-context-contributions";
|
|
8
12
|
import {
|
|
@@ -80,6 +84,16 @@ const CONTEXT_COMPACTIONS_METRIC = {
|
|
|
80
84
|
help: "Total completed context compactions, by trigger.",
|
|
81
85
|
} as const;
|
|
82
86
|
|
|
87
|
+
/** Logger plus the closed-label subscription-termination counter for NATS connections. */
|
|
88
|
+
export function observabilityEventBusOptions(
|
|
89
|
+
observability: Observability,
|
|
90
|
+
): Pick<EventBusOptions, "logger" | "onSubscriptionTerminated"> {
|
|
91
|
+
return {
|
|
92
|
+
logger: observabilityEventLogger(observability),
|
|
93
|
+
onSubscriptionTerminated: natsSubscriptionTerminationCounter(observability),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
export function observabilityEventLogger(observability: Observability): EventLogger {
|
|
84
98
|
return {
|
|
85
99
|
debug: (message, attributes) => observability.debug(message, eventAttributes(attributes)),
|
|
@@ -156,6 +170,13 @@ export function runtimeMetricsHooksForObservability(
|
|
|
156
170
|
labels: { backend, stage },
|
|
157
171
|
});
|
|
158
172
|
},
|
|
173
|
+
onSandboxReadinessReplacement: ({ backend, outcome }) => {
|
|
174
|
+
observability.incrementCounter({
|
|
175
|
+
name: "opengeni_sandbox_readiness_replacements_total",
|
|
176
|
+
help: "Fresh sandbox command-readiness replacement decisions by backend and outcome.",
|
|
177
|
+
labels: { backend, outcome },
|
|
178
|
+
});
|
|
179
|
+
},
|
|
159
180
|
onSandboxProviderApiThrottle: ({ backend, operation }) => {
|
|
160
181
|
observability.incrementCounter({
|
|
161
182
|
name: "opengeni_sandbox_provider_api_throttles_total",
|
|
@@ -2264,3 +2285,51 @@ export function modelCallAccountContext(input: {
|
|
|
2264
2285
|
accountChangedFromPrevCall,
|
|
2265
2286
|
};
|
|
2266
2287
|
}
|
|
2288
|
+
|
|
2289
|
+
export type CodeSearchCallOutcome =
|
|
2290
|
+
| "completed"
|
|
2291
|
+
| "jev_unavailable"
|
|
2292
|
+
| "jev_rejected"
|
|
2293
|
+
| "workspace_unavailable"
|
|
2294
|
+
| "invalid_arguments"
|
|
2295
|
+
| "breaker_open"
|
|
2296
|
+
| "cancelled"
|
|
2297
|
+
| "failed";
|
|
2298
|
+
|
|
2299
|
+
/** One `code_search` tool call: outcome, wall time and the Jev work it used. */
|
|
2300
|
+
export function recordCodeSearchCall(
|
|
2301
|
+
observability: Observability,
|
|
2302
|
+
input: {
|
|
2303
|
+
outcome: CodeSearchCallOutcome;
|
|
2304
|
+
durationSeconds: number;
|
|
2305
|
+
jevRequests: number;
|
|
2306
|
+
jevCostUsd: number;
|
|
2307
|
+
},
|
|
2308
|
+
): void {
|
|
2309
|
+
observability.incrementCounter({
|
|
2310
|
+
name: "opengeni_code_search_calls_total",
|
|
2311
|
+
help: "Jev-backed code_search tool calls by outcome.",
|
|
2312
|
+
labels: { outcome: input.outcome },
|
|
2313
|
+
});
|
|
2314
|
+
observability.observeHistogram({
|
|
2315
|
+
name: "opengeni_code_search_duration_seconds",
|
|
2316
|
+
help: "Wall time of one code_search tool call.",
|
|
2317
|
+
buckets: [0.5, 1, 2, 4, 8, 15, 30, 60],
|
|
2318
|
+
labels: { outcome: input.outcome },
|
|
2319
|
+
value: Math.max(0, input.durationSeconds),
|
|
2320
|
+
});
|
|
2321
|
+
if (input.jevRequests > 0) {
|
|
2322
|
+
observability.incrementCounter({
|
|
2323
|
+
name: "opengeni_code_search_jev_requests_total",
|
|
2324
|
+
help: "Jev requests made by code_search.",
|
|
2325
|
+
amount: input.jevRequests,
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
if (input.jevCostUsd > 0) {
|
|
2329
|
+
observability.incrementCounter({
|
|
2330
|
+
name: "opengeni_code_search_jev_cost_micro_usd_total",
|
|
2331
|
+
help: "Estimated Jev list-price cost of code_search, in micro-USD.",
|
|
2332
|
+
amount: Math.round(input.jevCostUsd * 1_000_000),
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
@@ -295,6 +295,8 @@ export function personalGitBrokerOrigin(settings: Settings): string | null {
|
|
|
295
295
|
(url.hostname === "host.docker.internal" ||
|
|
296
296
|
url.hostname === cachedDockerBridgeGateway ||
|
|
297
297
|
/^[a-z][a-z0-9-]{0,62}$/iu.test(url.hostname));
|
|
298
|
+
// The `local` and `test` environments accept any origin, which covers the
|
|
299
|
+
// local launcher's Linux Docker sandbox route on the Compose network gateway.
|
|
298
300
|
const privateExecutionOrigin =
|
|
299
301
|
(url.protocol === "http:" &&
|
|
300
302
|
(url.hostname === "127.0.0.1" || url.hostname === "localhost" || dockerPrivateOrigin)) ||
|
package/src/sandbox-resume.ts
CHANGED
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
withoutSandboxProviderIdentity,
|
|
82
82
|
type EstablishedSandboxSession,
|
|
83
83
|
type RuntimeMetricsHooks,
|
|
84
|
+
type SandboxReadinessReplacementOutcome,
|
|
84
85
|
type WorkspaceArchiveDescriptor,
|
|
85
86
|
} from "@opengeni/runtime";
|
|
86
87
|
import {
|
|
@@ -150,6 +151,24 @@ export type SandboxResumeServices = {
|
|
|
150
151
|
/** Test seam for the bounded command-readiness proof performed before an
|
|
151
152
|
* attached provider box is handed to the agent. */
|
|
152
153
|
verifyAttachedSandboxReadiness?: (established: EstablishedSandboxSession) => Promise<void>;
|
|
154
|
+
/** Test seam for the bounded command-readiness proof the elected spawner runs
|
|
155
|
+
* on its freshly created box before publishing the lease warm. Production
|
|
156
|
+
* uses {@link waitForSandboxExecReadiness} with the shared 60 s budget. */
|
|
157
|
+
verifySpawnedSandboxReadiness?: (
|
|
158
|
+
established: EstablishedSandboxSession,
|
|
159
|
+
identity: { sandboxGroupId: string },
|
|
160
|
+
) => Promise<void>;
|
|
161
|
+
/** Test seam for the jittered pause before the single fresh-box readiness
|
|
162
|
+
* replacement. Production uses {@link freshSandboxReadinessReplacementDelayMs}.
|
|
163
|
+
* It may be async so a test can observe the rolled-back lease in between. */
|
|
164
|
+
freshSandboxReadinessReplacementDelayMs?: () => number | Promise<number>;
|
|
165
|
+
/**
|
|
166
|
+
* The turn attempt's fresh-box readiness replacement budget. The lazy
|
|
167
|
+
* provisioner may call resumeBoxForTurn again after a typed lease
|
|
168
|
+
* supersession, so the turn creates the budget once and shares it with every
|
|
169
|
+
* call. Absent, the call gets its own single replacement.
|
|
170
|
+
*/
|
|
171
|
+
freshSandboxReadinessReplacementBudget?: FreshSandboxReadinessReplacementBudget;
|
|
153
172
|
/** Called only by the observer that wins the exact warm->cold loss CAS. */
|
|
154
173
|
onSandboxLost?: (input: {
|
|
155
174
|
sandboxGroupId: string;
|
|
@@ -318,6 +337,80 @@ export class SandboxSiblingWarmingTimeoutError extends SandboxWarmingTimeoutErro
|
|
|
318
337
|
}
|
|
319
338
|
}
|
|
320
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Out-of-band proof attached to one exact {@link SandboxExecReadinessTimeoutError}
|
|
342
|
+
* thrown by the elected spawner: the box that missed its readiness budget was
|
|
343
|
+
* freshly created by this caller (not an attached, resumed, or provider-continuity
|
|
344
|
+
* box), it was never published warm, the provider terminated it, and the holder
|
|
345
|
+
* was released. Only such an error may be replaced by re-entering admission.
|
|
346
|
+
*/
|
|
347
|
+
const disposedUnreadyFreshSandboxErrors = new WeakSet<object>();
|
|
348
|
+
|
|
349
|
+
/** True only for a spawner readiness timeout whose unready fresh box is proven gone. */
|
|
350
|
+
export function isReplaceableFreshSandboxReadinessTimeout(error: unknown): boolean {
|
|
351
|
+
return (
|
|
352
|
+
error instanceof SandboxExecReadinessTimeoutError &&
|
|
353
|
+
disposedUnreadyFreshSandboxErrors.has(error)
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A burst of simultaneous turn starts can leave many freshly created provider
|
|
359
|
+
* boxes command-unready at once. The single replacement re-enters admission
|
|
360
|
+
* after a jittered pause so the replacement creates do not re-synchronize into
|
|
361
|
+
* the same burst. Bounded to [2 s, 10 s).
|
|
362
|
+
*/
|
|
363
|
+
export const FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS = 2_000;
|
|
364
|
+
export const FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS = 8_000;
|
|
365
|
+
|
|
366
|
+
export function freshSandboxReadinessReplacementDelayMs(
|
|
367
|
+
random: () => number = Math.random,
|
|
368
|
+
): number {
|
|
369
|
+
const sample = random();
|
|
370
|
+
const unit = Number.isFinite(sample) ? Math.min(Math.max(sample, 0), 1 - Number.EPSILON) : 0;
|
|
371
|
+
return (
|
|
372
|
+
FRESH_SANDBOX_READINESS_REPLACEMENT_BASE_DELAY_MS +
|
|
373
|
+
Math.floor(unit * FRESH_SANDBOX_READINESS_REPLACEMENT_JITTER_MS)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Replacements allowed per turn attempt, across every resumeBoxForTurn call. */
|
|
378
|
+
export const FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT = 1;
|
|
379
|
+
|
|
380
|
+
export type FreshSandboxReadinessReplacementBudget = { remaining: number };
|
|
381
|
+
|
|
382
|
+
export function createFreshSandboxReadinessReplacementBudget(): FreshSandboxReadinessReplacementBudget {
|
|
383
|
+
return { remaining: FRESH_SANDBOX_READINESS_REPLACEMENTS_PER_TURN_ATTEMPT };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function recordFreshSandboxReadinessReplacement(
|
|
387
|
+
metrics: RuntimeMetricsHooks | undefined,
|
|
388
|
+
backend: string,
|
|
389
|
+
outcome: SandboxReadinessReplacementOutcome,
|
|
390
|
+
): void {
|
|
391
|
+
try {
|
|
392
|
+
metrics?.onSandboxReadinessReplacement?.({ backend, outcome });
|
|
393
|
+
} catch {
|
|
394
|
+
// Metrics emission must never affect sandbox recovery or error propagation.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function sleepUnlessCancelled(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
|
|
399
|
+
if (signal?.aborted) return false;
|
|
400
|
+
if (ms <= 0) return true;
|
|
401
|
+
return await new Promise<boolean>((resolve) => {
|
|
402
|
+
const onAbort = () => {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
resolve(false);
|
|
405
|
+
};
|
|
406
|
+
const timer = setTimeout(() => {
|
|
407
|
+
signal?.removeEventListener("abort", onAbort);
|
|
408
|
+
resolve(true);
|
|
409
|
+
}, ms);
|
|
410
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
321
414
|
/** The exact attached caller that won warm->cold after proving the provider
|
|
322
415
|
* instance gone. It is a lease supersession (the same logical turn recovers),
|
|
323
416
|
* plus the lost id needed for one durable observability event. */
|
|
@@ -1057,12 +1150,103 @@ async function persistWarmWorkspaceSnapshot(
|
|
|
1057
1150
|
* holderId is the globally unique durable turn-attempt id. It must not be a
|
|
1058
1151
|
* Temporal activity id, because activity ids are only workflow-local and
|
|
1059
1152
|
* collide when sibling sessions share one sandbox group.
|
|
1153
|
+
*
|
|
1154
|
+
* A freshly created box that misses its bounded command-readiness budget is
|
|
1155
|
+
* replaced at most once per turn attempt (the budget is shared by every call
|
|
1156
|
+
* the turn's provisioner makes). The first establish has already terminated that exact
|
|
1157
|
+
* unpublished box, rolled its exact warming epoch back to cold (advancing the
|
|
1158
|
+
* epoch), and released its holder, so the replacement is an ordinary new
|
|
1159
|
+
* admission: it re-runs the cold->warming CAS, persists its own provider
|
|
1160
|
+
* instance before readiness, and may instead attach to a sibling that won the
|
|
1161
|
+
* CAS first. Nothing model- or tool-visible ran on the discarded box, so no
|
|
1162
|
+
* side effect is replayed. A second readiness timeout fails the turn exactly
|
|
1163
|
+
* as before; attached/resumed boxes and unconfirmed terminations never
|
|
1164
|
+
* replace.
|
|
1060
1165
|
*/
|
|
1061
1166
|
export async function resumeBoxForTurn(
|
|
1062
1167
|
services: SandboxResumeServices,
|
|
1063
1168
|
ids: ResumeBoxIds,
|
|
1064
1169
|
kind: "turn",
|
|
1065
1170
|
holderId: TurnSandboxLeaseHolderId,
|
|
1171
|
+
): Promise<ResumedTurnSandbox> {
|
|
1172
|
+
try {
|
|
1173
|
+
return await resumeBoxForTurnOnce(services, ids, kind, holderId);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (!isReplaceableFreshSandboxReadinessTimeout(error)) throw error;
|
|
1176
|
+
const readiness = error as SandboxExecReadinessTimeoutError;
|
|
1177
|
+
const warn = (message: string, fields: Parameters<Observability["warn"]>[1]) => {
|
|
1178
|
+
if (services.observability) services.observability.warn(message, fields);
|
|
1179
|
+
else console.warn(message, fields);
|
|
1180
|
+
};
|
|
1181
|
+
const identity = {
|
|
1182
|
+
workspaceId: ids.workspaceId,
|
|
1183
|
+
sessionId: ids.sessionId,
|
|
1184
|
+
sandboxGroupId: ids.sandboxGroupId,
|
|
1185
|
+
backend: readiness.backend,
|
|
1186
|
+
instanceId: readiness.instanceId,
|
|
1187
|
+
readinessTimeoutMs: readiness.timeoutMs,
|
|
1188
|
+
};
|
|
1189
|
+
const budget =
|
|
1190
|
+
services.freshSandboxReadinessReplacementBudget ??
|
|
1191
|
+
createFreshSandboxReadinessReplacementBudget();
|
|
1192
|
+
if (budget.remaining <= 0) {
|
|
1193
|
+
recordFreshSandboxReadinessReplacement(
|
|
1194
|
+
services.sandboxMetrics,
|
|
1195
|
+
readiness.backend,
|
|
1196
|
+
"budget_spent",
|
|
1197
|
+
);
|
|
1198
|
+
warn(
|
|
1199
|
+
"sandbox command-readiness timed out on a fresh box; this turn attempt already used its replacement",
|
|
1200
|
+
identity,
|
|
1201
|
+
);
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
budget.remaining -= 1;
|
|
1205
|
+
const delayMs = await (
|
|
1206
|
+
services.freshSandboxReadinessReplacementDelayMs ?? freshSandboxReadinessReplacementDelayMs
|
|
1207
|
+
)();
|
|
1208
|
+
warn("sandbox command-readiness timed out on a fresh box; replacing it once after jitter", {
|
|
1209
|
+
...identity,
|
|
1210
|
+
replacementDelayMs: delayMs,
|
|
1211
|
+
});
|
|
1212
|
+
if (!(await sleepUnlessCancelled(delayMs, services.cancellationSignal))) {
|
|
1213
|
+
// Cancellation owns the turn boundary; surface the original typed
|
|
1214
|
+
// readiness failure rather than starting a replacement.
|
|
1215
|
+
recordFreshSandboxReadinessReplacement(
|
|
1216
|
+
services.sandboxMetrics,
|
|
1217
|
+
readiness.backend,
|
|
1218
|
+
"cancelled",
|
|
1219
|
+
);
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
const replaced = await resumeBoxForTurnOnce(services, ids, kind, holderId);
|
|
1224
|
+
recordFreshSandboxReadinessReplacement(
|
|
1225
|
+
services.sandboxMetrics,
|
|
1226
|
+
readiness.backend,
|
|
1227
|
+
"replaced",
|
|
1228
|
+
);
|
|
1229
|
+
return replaced;
|
|
1230
|
+
} catch (replacementError) {
|
|
1231
|
+
recordFreshSandboxReadinessReplacement(
|
|
1232
|
+
services.sandboxMetrics,
|
|
1233
|
+
readiness.backend,
|
|
1234
|
+
services.cancellationSignal?.aborted
|
|
1235
|
+
? "cancelled"
|
|
1236
|
+
: replacementError instanceof SandboxExecReadinessTimeoutError
|
|
1237
|
+
? "failed_again"
|
|
1238
|
+
: "replacement_failed",
|
|
1239
|
+
);
|
|
1240
|
+
throw replacementError;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function resumeBoxForTurnOnce(
|
|
1246
|
+
services: SandboxResumeServices,
|
|
1247
|
+
ids: ResumeBoxIds,
|
|
1248
|
+
kind: "turn",
|
|
1249
|
+
holderId: TurnSandboxLeaseHolderId,
|
|
1066
1250
|
): Promise<ResumedTurnSandbox> {
|
|
1067
1251
|
const { db, settings } = services;
|
|
1068
1252
|
const os = ids.os ?? "linux";
|
|
@@ -1656,11 +1840,17 @@ export async function resumeBoxForTurn(
|
|
|
1656
1840
|
// A sandbox handle is not sufficient evidence that an asynchronous
|
|
1657
1841
|
// provider's command router is live. Do not publish a warm lease until
|
|
1658
1842
|
// one bounded no-op exec works.
|
|
1659
|
-
// On timeout the catch below terminates the box and rolls warming -> cold
|
|
1660
|
-
//
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1843
|
+
// On timeout the catch below terminates the box and rolls warming -> cold;
|
|
1844
|
+
// resumeBoxForTurn may then replace a proven-terminated fresh box once.
|
|
1845
|
+
if (services.verifySpawnedSandboxReadiness) {
|
|
1846
|
+
await services.verifySpawnedSandboxReadiness(established, {
|
|
1847
|
+
sandboxGroupId: ids.sandboxGroupId,
|
|
1848
|
+
});
|
|
1849
|
+
} else {
|
|
1850
|
+
await waitForSandboxExecReadiness(established, MODAL_EXEC_READINESS_TIMEOUT_MS, {
|
|
1851
|
+
sandboxGroupId: ids.sandboxGroupId,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1664
1854
|
await maybeRenewProviderExpiration(true);
|
|
1665
1855
|
throwIfReleasedOrCancelled();
|
|
1666
1856
|
// Fold the LIVE box into a re-resumable envelope and persist it as the
|
|
@@ -1796,6 +1986,16 @@ export async function resumeBoxForTurn(
|
|
|
1796
1986
|
}
|
|
1797
1987
|
await release();
|
|
1798
1988
|
recordSandboxWarmingTimeout(services.sandboxMetrics, error);
|
|
1989
|
+
if (
|
|
1990
|
+
terminated &&
|
|
1991
|
+
createdEstablished &&
|
|
1992
|
+
error instanceof SandboxExecReadinessTimeoutError &&
|
|
1993
|
+
error.instanceId === createdEstablished.instanceId &&
|
|
1994
|
+
(createdEstablished.origin === "created" || createdEstablished.origin === "restored") &&
|
|
1995
|
+
!createdEstablished.providerContinuity
|
|
1996
|
+
) {
|
|
1997
|
+
disposedUnreadyFreshSandboxErrors.add(error);
|
|
1998
|
+
}
|
|
1799
1999
|
throw sandboxProvisionStageError(rematerialization ? "archive_recovery" : "create", error);
|
|
1800
2000
|
}
|
|
1801
2001
|
}
|