@opengeni/worker-bundle 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activities/agent-turn/failure-settlement.d.ts +1 -1
- package/dist/activities/agent-turn/history.d.ts +2 -0
- package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
- package/dist/activities/retained-screenshots.d.ts +7 -2
- package/dist/activities/sandbox-lease.d.ts +3 -4
- package/dist/{activities-control-QA6OLRQY.js → activities-control-IVTL723D.js} +204 -23
- package/dist/activities-control-IVTL723D.js.map +1 -0
- package/dist/{activities-turn-YVVKCGAK.js → activities-turn-EZEDZTEZ.js} +108 -33
- package/dist/activities-turn-EZEDZTEZ.js.map +1 -0
- package/dist/{chunk-M3NVMF2V.js → chunk-JMO5ZUW3.js} +14 -7
- package/dist/{chunk-M3NVMF2V.js.map → chunk-JMO5ZUW3.js.map} +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/workflow-bundle.js +4 -1
- package/package.json +15 -15
- package/src/activities/agent-turn/errors.ts +8 -3
- package/src/activities/agent-turn/failure-settlement.ts +40 -8
- package/src/activities/agent-turn/history.ts +14 -1
- package/src/activities/agent-turn/media-artifacts.ts +2 -1
- package/src/activities/agent-turn/sandbox-runtime.ts +25 -9
- package/src/activities/agent-turn/stream-attempt.ts +13 -2
- package/src/activities/knowledge-indexing.ts +133 -6
- package/src/activities/retained-screenshots.ts +82 -11
- package/src/activities/sandbox-lease.ts +141 -21
- package/src/sandbox-resume.ts +6 -0
- package/src/workflows/activities.ts +14 -1
- package/dist/activities-control-QA6OLRQY.js.map +0 -1
- package/dist/activities-turn-YVVKCGAK.js.map +0 -1
|
@@ -6,13 +6,16 @@ import {
|
|
|
6
6
|
COMPUTER_SCREENSHOT_WORKSPACE_QUOTA_BYTES,
|
|
7
7
|
RetainedArtifactMetadataSchema,
|
|
8
8
|
retainedScreenshotReferenceFromFile,
|
|
9
|
+
retainedSessionScreenshotKindFromObjectKey,
|
|
9
10
|
type RetainedArtifactMetadata,
|
|
10
11
|
type RetainedArtifactReference,
|
|
11
12
|
type RetainedOutputUnavailableReason,
|
|
13
|
+
type RetainedSessionScreenshotKind,
|
|
12
14
|
} from "@opengeni/contracts";
|
|
13
15
|
import {
|
|
14
16
|
RetainedScreenshotQuotaExceededError,
|
|
15
17
|
getRetainedScreenshotArtifact,
|
|
18
|
+
getRetainedScreenshotArtifactForToolCall,
|
|
16
19
|
isDatabasePersistenceFailure,
|
|
17
20
|
isSessionEventPersistenceError,
|
|
18
21
|
prepareRetainedScreenshotArtifact,
|
|
@@ -209,14 +212,26 @@ export function toolOutputContainsInlineImage(output: unknown): boolean {
|
|
|
209
212
|
return (
|
|
210
213
|
(record.type === "input_image" &&
|
|
211
214
|
sdkImageSourceContainsInlineImage(record.image ?? record.image_url ?? record.imageUrl)) ||
|
|
212
|
-
(record.type === "image" &&
|
|
215
|
+
(record.type === "image" && isDeclaredInlineMcpImage(record))
|
|
213
216
|
);
|
|
214
217
|
});
|
|
215
218
|
}
|
|
216
219
|
if (!output || typeof output !== "object") return false;
|
|
217
220
|
const record = output as Record<string, unknown>;
|
|
218
221
|
if (Array.isArray(record.content)) return toolOutputContainsInlineImage(record.content);
|
|
219
|
-
return
|
|
222
|
+
return (
|
|
223
|
+
record.type === "image" &&
|
|
224
|
+
(sdkImageSourceContainsInlineImage(record.image) || isDeclaredInlineMcpImage(record))
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Detect image-bearing MCP blocks even when decoding rejects oversized or malformed bytes. */
|
|
229
|
+
function isDeclaredInlineMcpImage(record: Record<string, unknown>): boolean {
|
|
230
|
+
return (
|
|
231
|
+
typeof record.data === "string" &&
|
|
232
|
+
typeof record.mimeType === "string" &&
|
|
233
|
+
record.mimeType.toLowerCase().startsWith("image/")
|
|
234
|
+
);
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
function decodeInlineMcpImage(
|
|
@@ -520,9 +535,14 @@ export function retainedScreenshotIdentity(input: {
|
|
|
520
535
|
attemptId: string;
|
|
521
536
|
toolCallId: string;
|
|
522
537
|
toolOutputId: string;
|
|
538
|
+
kind?: RetainedSessionScreenshotKind | undefined;
|
|
523
539
|
}): { artifactId: string; settlementKey: string } {
|
|
524
540
|
const settlementKey = createHash("sha256")
|
|
525
|
-
.update(
|
|
541
|
+
.update(
|
|
542
|
+
input.kind === "browser_screenshot"
|
|
543
|
+
? "opengeni:browser-screenshot:v1\0"
|
|
544
|
+
: "opengeni:computer-screenshot:v1\0",
|
|
545
|
+
)
|
|
526
546
|
.update(input.sessionId)
|
|
527
547
|
.update("\0")
|
|
528
548
|
.update(input.turnId)
|
|
@@ -551,12 +571,13 @@ export function unavailableRetainedSessionImage(input: {
|
|
|
551
571
|
toolCallId: string;
|
|
552
572
|
toolOutputId: string;
|
|
553
573
|
reason: RetainedOutputUnavailableReason;
|
|
574
|
+
kind?: RetainedSessionScreenshotKind | undefined;
|
|
554
575
|
}): RetainedArtifactMetadata {
|
|
555
576
|
const identity = retainedScreenshotIdentity(input);
|
|
556
577
|
return unavailable(identity.artifactId, input.reason);
|
|
557
578
|
}
|
|
558
579
|
|
|
559
|
-
export async function
|
|
580
|
+
export async function retainSessionScreenshot(input: {
|
|
560
581
|
db: Database;
|
|
561
582
|
objectStorage: ObjectStorage | null;
|
|
562
583
|
accountId: string;
|
|
@@ -565,6 +586,7 @@ export async function retainComputerScreenshot(input: {
|
|
|
565
586
|
turnId: string;
|
|
566
587
|
attemptId: string;
|
|
567
588
|
output: TypedScreenshotToolOutput;
|
|
589
|
+
kind?: RetainedSessionScreenshotKind | undefined;
|
|
568
590
|
now?: Date;
|
|
569
591
|
retentionMs?: number;
|
|
570
592
|
workspaceQuotaBytes?: number;
|
|
@@ -575,6 +597,7 @@ export async function retainComputerScreenshot(input: {
|
|
|
575
597
|
attemptId: input.attemptId,
|
|
576
598
|
toolCallId: input.output.callId,
|
|
577
599
|
toolOutputId: input.output.toolOutputId,
|
|
600
|
+
kind: input.kind,
|
|
578
601
|
});
|
|
579
602
|
let screenshot: ValidatedSessionImage;
|
|
580
603
|
try {
|
|
@@ -594,7 +617,8 @@ export async function retainComputerScreenshot(input: {
|
|
|
594
617
|
const retentionExpiresAt = new Date(
|
|
595
618
|
now.getTime() + (input.retentionMs ?? COMPUTER_SCREENSHOT_RETENTION_MS),
|
|
596
619
|
);
|
|
597
|
-
const
|
|
620
|
+
const objectName = input.kind === "browser_screenshot" ? "browser-screenshot" : "session-image";
|
|
621
|
+
const objectKey = `workspaces/${input.workspaceId}/files/${identity.artifactId}/retained/${objectName}.${screenshot.extension}`;
|
|
598
622
|
let prepared: RetainedScreenshotArtifact;
|
|
599
623
|
try {
|
|
600
624
|
prepared = (
|
|
@@ -697,6 +721,9 @@ export async function retainComputerScreenshot(input: {
|
|
|
697
721
|
}
|
|
698
722
|
}
|
|
699
723
|
|
|
724
|
+
/** Existing computer callers keep their stable entry point. */
|
|
725
|
+
export const retainComputerScreenshot = retainSessionScreenshot;
|
|
726
|
+
|
|
700
727
|
/** Replace only new screenshot bytes with a compact receipt before first persistence. */
|
|
701
728
|
export function compactRetainedScreenshotHistory(
|
|
702
729
|
history: Array<Record<string, unknown>>,
|
|
@@ -874,6 +901,26 @@ async function materializeRetainedScreenshotHistoryWithCache(
|
|
|
874
901
|
cache: Map<string, string>,
|
|
875
902
|
): Promise<Array<Record<string, unknown>>> {
|
|
876
903
|
const now = input.now ?? new Date();
|
|
904
|
+
const receiptForMarker = async (
|
|
905
|
+
marker: unknown,
|
|
906
|
+
callId: string | null,
|
|
907
|
+
): Promise<RetainedArtifactMetadata | null> => {
|
|
908
|
+
if (!isRetainedImageMarker(marker)) return null;
|
|
909
|
+
const receipt = retainedReceipt(marker.artifact);
|
|
910
|
+
if (receipt) return receipt;
|
|
911
|
+
// Older canonical history could truncate the artifact UUID and reason as
|
|
912
|
+
// ordinary text. Recover only from this exact session and an unambiguous
|
|
913
|
+
// tool call; otherwise fail before constructing a malformed model image.
|
|
914
|
+
if (!callId) throw new Error("Retained screenshot receipt has no tool call identity");
|
|
915
|
+
const artifact = await getRetainedScreenshotArtifactForToolCall(
|
|
916
|
+
input.db,
|
|
917
|
+
input.workspaceId,
|
|
918
|
+
input.sessionId,
|
|
919
|
+
callId,
|
|
920
|
+
);
|
|
921
|
+
if (!artifact) throw new Error("Retained screenshot receipt cannot be recovered");
|
|
922
|
+
return reference(artifact);
|
|
923
|
+
};
|
|
877
924
|
const dataUrlForReceipt = async (receipt: RetainedArtifactMetadata): Promise<string> => {
|
|
878
925
|
let dataUrl = cache.get(receipt.artifactId);
|
|
879
926
|
if (!dataUrl) {
|
|
@@ -921,8 +968,12 @@ async function materializeRetainedScreenshotHistoryWithCache(
|
|
|
921
968
|
}
|
|
922
969
|
return dataUrl;
|
|
923
970
|
};
|
|
924
|
-
const materializeEntry = async (entry: unknown): Promise<unknown> => {
|
|
925
|
-
const
|
|
971
|
+
const materializeEntry = async (entry: unknown, callId: string | null): Promise<unknown> => {
|
|
972
|
+
const image =
|
|
973
|
+
entry && typeof entry === "object" && !Array.isArray(entry)
|
|
974
|
+
? (entry as Record<string, unknown>).image
|
|
975
|
+
: null;
|
|
976
|
+
const receipt = await receiptForMarker(image, callId);
|
|
926
977
|
if (!receipt) return entry;
|
|
927
978
|
const dataUrl = await dataUrlForReceipt(receipt);
|
|
928
979
|
return { ...(entry as Record<string, unknown>), image: dataUrl };
|
|
@@ -930,7 +981,8 @@ async function materializeRetainedScreenshotHistoryWithCache(
|
|
|
930
981
|
|
|
931
982
|
const materialized: Array<Record<string, unknown>> = [];
|
|
932
983
|
for (const item of input.history) {
|
|
933
|
-
const
|
|
984
|
+
const callId = historyCallId(item);
|
|
985
|
+
const directReceipt = await receiptForMarker(item.output, callId);
|
|
934
986
|
if (directReceipt) {
|
|
935
987
|
materialized.push({
|
|
936
988
|
...item,
|
|
@@ -954,7 +1006,7 @@ async function materializeRetainedScreenshotHistoryWithCache(
|
|
|
954
1006
|
let changed = false;
|
|
955
1007
|
const output: unknown[] = [];
|
|
956
1008
|
for (const entry of outputEntries) {
|
|
957
|
-
const next = await materializeEntry(entry);
|
|
1009
|
+
const next = await materializeEntry(entry, callId);
|
|
958
1010
|
changed ||= next !== entry;
|
|
959
1011
|
output.push(next);
|
|
960
1012
|
}
|
|
@@ -1059,12 +1111,15 @@ function reference(artifact: RetainedScreenshotArtifact): RetainedArtifactRefere
|
|
|
1059
1111
|
if (!artifact.sessionId) {
|
|
1060
1112
|
throw new Error(`Ready retained screenshot is detached: ${artifact.artifactId}`);
|
|
1061
1113
|
}
|
|
1114
|
+
const kind = retainedSessionScreenshotKindFromObjectKey(artifact.file.objectKey);
|
|
1115
|
+
if (!kind) throw new Error(`Ready retained screenshot has unknown key: ${artifact.artifactId}`);
|
|
1062
1116
|
const value = retainedScreenshotReferenceFromFile({
|
|
1063
1117
|
...artifact.file,
|
|
1064
1118
|
sessionId: artifact.sessionId,
|
|
1065
1119
|
width: artifact.width,
|
|
1066
1120
|
height: artifact.height,
|
|
1067
1121
|
expiresAt: artifact.retentionExpiresAt.toISOString(),
|
|
1122
|
+
kind,
|
|
1068
1123
|
});
|
|
1069
1124
|
if (!value)
|
|
1070
1125
|
throw new Error(`Ready retained screenshot receipt is invalid: ${artifact.artifactId}`);
|
|
@@ -1110,6 +1165,18 @@ function retainedReceiptFromImageContent(entry: unknown): RetainedArtifactMetada
|
|
|
1110
1165
|
return retainedReceipt(marker.artifact);
|
|
1111
1166
|
}
|
|
1112
1167
|
|
|
1168
|
+
function isRetainedImageMarker(value: unknown): value is {
|
|
1169
|
+
type: "retained_artifact";
|
|
1170
|
+
artifact: unknown;
|
|
1171
|
+
} {
|
|
1172
|
+
return Boolean(
|
|
1173
|
+
value &&
|
|
1174
|
+
typeof value === "object" &&
|
|
1175
|
+
!Array.isArray(value) &&
|
|
1176
|
+
(value as Record<string, unknown>).type === RETAINED_IMAGE_MARKER,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1113
1180
|
function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetadata | null {
|
|
1114
1181
|
if (!output || typeof output !== "object" || Array.isArray(output)) return null;
|
|
1115
1182
|
const marker = output as Record<string, unknown>;
|
|
@@ -1120,7 +1187,11 @@ function retainedReceiptFromDirectOutput(output: unknown): RetainedArtifactMetad
|
|
|
1120
1187
|
function retainedReceipt(value: unknown): RetainedArtifactMetadata | null {
|
|
1121
1188
|
const parsed = RetainedArtifactMetadataSchema.safeParse(value);
|
|
1122
1189
|
if (!parsed.success) return null;
|
|
1123
|
-
return parsed.data.available &&
|
|
1190
|
+
return parsed.data.available &&
|
|
1191
|
+
parsed.data.kind !== "computer_screenshot" &&
|
|
1192
|
+
parsed.data.kind !== "browser_screenshot"
|
|
1193
|
+
? null
|
|
1194
|
+
: parsed.data;
|
|
1124
1195
|
}
|
|
1125
1196
|
|
|
1126
1197
|
function isInlineImageContent(entry: unknown): boolean {
|
|
@@ -1130,7 +1201,7 @@ function isInlineImageContent(entry: unknown): boolean {
|
|
|
1130
1201
|
(record.type === "input_image" &&
|
|
1131
1202
|
typeof record.image === "string" &&
|
|
1132
1203
|
record.image.startsWith("data:image/")) ||
|
|
1133
|
-
(record.type === "image" &&
|
|
1204
|
+
(record.type === "image" && isDeclaredInlineMcpImage(record))
|
|
1134
1205
|
);
|
|
1135
1206
|
}
|
|
1136
1207
|
|
|
@@ -39,10 +39,10 @@ import {
|
|
|
39
39
|
countSandboxLeasesByLiveness,
|
|
40
40
|
deferRetainedProcessReconciliation,
|
|
41
41
|
forceDrainOverLimitViewerOnlyBoxes,
|
|
42
|
-
getBillingBalance,
|
|
43
42
|
listCreditBalancesByAccount,
|
|
44
43
|
listLegacyModalCheckpointSlots,
|
|
45
44
|
listLiveModalSandboxLeaseAttributions,
|
|
45
|
+
markWarmBillingStopCutoff,
|
|
46
46
|
listMeterableWarmLeases,
|
|
47
47
|
listSandboxViewerForceDrainWorkspaceIds,
|
|
48
48
|
markSandboxCheckpointArtifactDeletePending,
|
|
@@ -186,8 +186,7 @@ export type ReapSandboxLeasesResult = {
|
|
|
186
186
|
skipped: number;
|
|
187
187
|
/** Warm viewer-only leases that accrued warm-seconds this tick (P2.1). */
|
|
188
188
|
metered: number;
|
|
189
|
-
/**
|
|
190
|
-
* (0 balance / over the warm cap) — turn-held boxes are never drained (P2.1). */
|
|
189
|
+
/** Holderless boxes accelerated to draining by the optional warm cap. */
|
|
191
190
|
forceDrained: number;
|
|
192
191
|
/** Provider-side Modal orphan sandboxes terminated by the defensive sweep. */
|
|
193
192
|
modalOrphansTerminated: number;
|
|
@@ -256,6 +255,7 @@ export type TerminateBoxFn = (
|
|
|
256
255
|
diskBackedArchives?: boolean,
|
|
257
256
|
releaseFailedCapture?: () => Promise<void>,
|
|
258
257
|
workspaceId?: string,
|
|
258
|
+
beforeProviderStop?: () => Promise<void>,
|
|
259
259
|
) => Promise<boolean | ProviderTerminationOutcome>;
|
|
260
260
|
|
|
261
261
|
export type SweepModalOrphansFn = (
|
|
@@ -482,7 +482,14 @@ export function createSandboxLeaseActivities(
|
|
|
482
482
|
options: SandboxLeaseActivityOptions = {},
|
|
483
483
|
) {
|
|
484
484
|
const terminateBox: TerminateBoxFn =
|
|
485
|
-
options.terminateBox
|
|
485
|
+
(options.terminateBox
|
|
486
|
+
? async (...args: Parameters<TerminateBoxFn>) => {
|
|
487
|
+
// The injected test seam represents the stop itself; the real
|
|
488
|
+
// implementation invokes this only after capture has succeeded.
|
|
489
|
+
await args[10]?.();
|
|
490
|
+
return options.terminateBox!(...args);
|
|
491
|
+
}
|
|
492
|
+
: null) ??
|
|
486
493
|
(async (
|
|
487
494
|
settings,
|
|
488
495
|
lease,
|
|
@@ -494,6 +501,7 @@ export function createSandboxLeaseActivities(
|
|
|
494
501
|
diskBackedArchives,
|
|
495
502
|
releaseFailedCapture,
|
|
496
503
|
workspaceId,
|
|
504
|
+
beforeProviderStop,
|
|
497
505
|
) =>
|
|
498
506
|
await terminateProviderBox(
|
|
499
507
|
settings,
|
|
@@ -508,6 +516,7 @@ export function createSandboxLeaseActivities(
|
|
|
508
516
|
diskBackedArchives,
|
|
509
517
|
releaseFailedCapture,
|
|
510
518
|
workspaceId,
|
|
519
|
+
beforeProviderStop,
|
|
511
520
|
));
|
|
512
521
|
const sweepModalOrphans: SweepModalOrphansFn =
|
|
513
522
|
options.sweepModalOrphans ?? sweepModalOrphansForConfiguredBackend;
|
|
@@ -735,8 +744,7 @@ export function createSandboxLeaseActivities(
|
|
|
735
744
|
metered = meterResult.accrued;
|
|
736
745
|
const forceDrainWorkspaceIds = new Set<string>();
|
|
737
746
|
if (
|
|
738
|
-
settings.
|
|
739
|
-
settings.usageLimitsMode === "managed" ||
|
|
747
|
+
settings.sandboxWarmBillingMode === "credits" ||
|
|
740
748
|
settings.sandboxMaxWarmSecondsPerWorkspace > 0
|
|
741
749
|
) {
|
|
742
750
|
for (const workspaceId of meterResult.workspaceIds) {
|
|
@@ -1507,6 +1515,25 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1507
1515
|
}
|
|
1508
1516
|
}
|
|
1509
1517
|
|
|
1518
|
+
if (
|
|
1519
|
+
observation?.status === "deferred" &&
|
|
1520
|
+
process.providerBackend === "modal" &&
|
|
1521
|
+
!process.providerBindingKey &&
|
|
1522
|
+
!process.deadlineCancellationRequestedAt
|
|
1523
|
+
) {
|
|
1524
|
+
// An unbound historical command cannot be signalled safely. Still
|
|
1525
|
+
// record deadline intent when its lease matches: a failed observer
|
|
1526
|
+
// must not block capture until the provider expires.
|
|
1527
|
+
try {
|
|
1528
|
+
await requestRetainedProcessDeadlineCancellation(db, processScope);
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
observability.warn("sandbox reaper: legacy deadline cancellation intent failed", {
|
|
1531
|
+
processId: process.id,
|
|
1532
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1510
1537
|
if (observation === null) {
|
|
1511
1538
|
const lease = await readLease(db, process.workspaceId, process.sandboxGroupId).catch(
|
|
1512
1539
|
() => null,
|
|
@@ -1583,11 +1610,19 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1583
1610
|
);
|
|
1584
1611
|
supervisionMetric("cancellation_intent");
|
|
1585
1612
|
}
|
|
1586
|
-
|
|
1613
|
+
const legacyDeadlineStop =
|
|
1614
|
+
lease!.rotationReason === "provider_deadline" &&
|
|
1615
|
+
!supervised &&
|
|
1616
|
+
!process.deadlineCancellationRequestedAt;
|
|
1617
|
+
const probeOutcome = await probe(
|
|
1587
1618
|
settings,
|
|
1588
1619
|
lease!,
|
|
1589
1620
|
process,
|
|
1590
|
-
claim.ownerState === "background_stopping"
|
|
1621
|
+
(claim.ownerState === "background_stopping" &&
|
|
1622
|
+
!process.deadlineCancellationRequestedAt) ||
|
|
1623
|
+
legacyDeadlineStop
|
|
1624
|
+
? "cancel"
|
|
1625
|
+
: "observe",
|
|
1591
1626
|
async (result, chunkId, stream, streamFidelity) => {
|
|
1592
1627
|
if (
|
|
1593
1628
|
typeof result !== "string" ||
|
|
@@ -1610,7 +1645,20 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1610
1645
|
.catch(() => undefined);
|
|
1611
1646
|
},
|
|
1612
1647
|
commandPersistence,
|
|
1648
|
+
).then(
|
|
1649
|
+
(value) => ({ ok: true as const, value }),
|
|
1650
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
1613
1651
|
);
|
|
1652
|
+
// Legacy PTY input reserves Ctrl-C before the cancellation fence.
|
|
1653
|
+
// Non-PTY has no command signal in the Modal SDK: probe it without
|
|
1654
|
+
// injecting Ctrl-C as data, then fence and save after the grace.
|
|
1655
|
+
if (
|
|
1656
|
+
legacyDeadlineStop &&
|
|
1657
|
+
!(await requestRetainedProcessDeadlineCancellation(db, processScope))
|
|
1658
|
+
)
|
|
1659
|
+
throw new Error("Deadline cancellation no longer owns its rotating lease");
|
|
1660
|
+
if (!probeOutcome.ok) throw probeOutcome.error;
|
|
1661
|
+
observation = probeOutcome.value;
|
|
1614
1662
|
if (supervised) {
|
|
1615
1663
|
supervisionMetric(
|
|
1616
1664
|
(await commandPersistence.loadSupervisionReceipt())
|
|
@@ -1899,7 +1947,13 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1899
1947
|
if (pending) {
|
|
1900
1948
|
if (!captureOutput) throw new Error("Pending command output requires its persistence callback");
|
|
1901
1949
|
await captureRetainedProbeOutput(process.id, pending.result, captureOutput);
|
|
1902
|
-
|
|
1950
|
+
const pendingObservation = classifyRetainedProcessPollResult(
|
|
1951
|
+
pending.result,
|
|
1952
|
+
process.providerSessionId,
|
|
1953
|
+
);
|
|
1954
|
+
// Capture of a previous page must not consume the only deadline stop
|
|
1955
|
+
// attempt. Terminal proof needs no further provider call.
|
|
1956
|
+
if (mode !== "cancel" || pendingObservation.status === "proved") return pendingObservation;
|
|
1903
1957
|
}
|
|
1904
1958
|
const envelopeBackend = (lease.resumeState as { backendId?: unknown }).backendId;
|
|
1905
1959
|
if (
|
|
@@ -1996,6 +2050,7 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1996
2050
|
if (typeof session.writeStdin !== "function") {
|
|
1997
2051
|
return { status: "deferred", reason: "backend_unsupported" };
|
|
1998
2052
|
}
|
|
2053
|
+
let legacyPty = false;
|
|
1999
2054
|
if (process.providerBackend === "modal") {
|
|
2000
2055
|
let liveBinding: Awaited<ReturnType<typeof resolveModalCheckpointProviderBindingForSession>>;
|
|
2001
2056
|
try {
|
|
@@ -2027,6 +2082,7 @@ export async function probeRetainedProcessAtProvider(
|
|
|
2027
2082
|
if (!command || !providerPersistence || !session.bindProviderCommand)
|
|
2028
2083
|
return { status: "deferred", reason: "process_observation_unavailable" };
|
|
2029
2084
|
session.bindProviderCommand(process.providerSessionId, command, providerPersistence);
|
|
2085
|
+
legacyPty = command.pty === true;
|
|
2030
2086
|
}
|
|
2031
2087
|
const capturePage = async (value: unknown): Promise<void> => {
|
|
2032
2088
|
if (!captureOutput) {
|
|
@@ -2062,7 +2118,14 @@ export async function probeRetainedProcessAtProvider(
|
|
|
2062
2118
|
result = await withRetainedProcessProbeTimeout(
|
|
2063
2119
|
session.writeStdin({
|
|
2064
2120
|
sessionId: process.providerSessionId,
|
|
2065
|
-
|
|
2121
|
+
// Ctrl-C is a signal only through a PTY. For a non-PTY command it is
|
|
2122
|
+
// ordinary stdin data and can corrupt the command's input.
|
|
2123
|
+
chars:
|
|
2124
|
+
mode === "cancel" &&
|
|
2125
|
+
!supervisedCancelled &&
|
|
2126
|
+
(process.providerBackend !== "modal" || legacyPty)
|
|
2127
|
+
? "\u0003"
|
|
2128
|
+
: "",
|
|
2066
2129
|
yieldTimeMs: 1_000,
|
|
2067
2130
|
maxOutputTokens: 2_000,
|
|
2068
2131
|
}),
|
|
@@ -2092,6 +2155,9 @@ export async function probeRetainedProcessAtProvider(
|
|
|
2092
2155
|
observation.status === "deferred" &&
|
|
2093
2156
|
observation.reason === "provider_running" &&
|
|
2094
2157
|
lease.rotationRequestedAt !== null &&
|
|
2158
|
+
mode !== "cancel" &&
|
|
2159
|
+
!process.cancellationRequestedAt &&
|
|
2160
|
+
legacyPty &&
|
|
2095
2161
|
!(await providerPersistence
|
|
2096
2162
|
?.load()
|
|
2097
2163
|
.then((command) => command?.kind === "modal-router-v1" && command.supervision))
|
|
@@ -2197,19 +2263,25 @@ async function accrueWarmTick(
|
|
|
2197
2263
|
}
|
|
2198
2264
|
await forEachWithConcurrency(leases, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (lease) => {
|
|
2199
2265
|
try {
|
|
2200
|
-
const rate =
|
|
2266
|
+
const rate =
|
|
2267
|
+
settings.sandboxWarmBillingMode === "usage_only"
|
|
2268
|
+
? 0
|
|
2269
|
+
: sandboxWarmRateMicrosPerSecond(settings, lease.backend);
|
|
2201
2270
|
const result = await accrueWarmSeconds(db, {
|
|
2202
2271
|
accountId: lease.accountId,
|
|
2203
2272
|
workspaceId: lease.workspaceId,
|
|
2204
2273
|
sandboxGroupId: lease.sandboxGroupId,
|
|
2205
2274
|
expectedEpoch: lease.leaseEpoch,
|
|
2206
2275
|
warmRateMicrosPerSecond: rate,
|
|
2276
|
+
billingMode: settings.sandboxWarmBillingMode,
|
|
2207
2277
|
subjectId: lease.sandboxGroupId,
|
|
2208
2278
|
});
|
|
2209
2279
|
if (result.accrued) {
|
|
2210
2280
|
accrued += 1;
|
|
2211
2281
|
}
|
|
2212
|
-
|
|
2282
|
+
if (result.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
|
|
2283
|
+
recordCreditMicros(observability, "usage", result.costMicros);
|
|
2284
|
+
}
|
|
2213
2285
|
} catch (error) {
|
|
2214
2286
|
observability.warn("sandbox reaper: warm-seconds accrual failed for lease", {
|
|
2215
2287
|
workspaceId: lease.workspaceId,
|
|
@@ -2325,8 +2397,8 @@ async function refreshSandboxInventoryGauge(
|
|
|
2325
2397
|
|
|
2326
2398
|
/**
|
|
2327
2399
|
* The per-workspace warm-cap + force-drain pass (P2.1). For each workspace with a
|
|
2328
|
-
* live warm box, under the usage lock: if it is
|
|
2329
|
-
*
|
|
2400
|
+
* live warm box, under the usage lock: if it is over its optional warm cap,
|
|
2401
|
+
* accelerate only holderless boxes to draining
|
|
2330
2402
|
* (guarded turn_holders=0 — a paying turn is never killed). Returns the count of
|
|
2331
2403
|
* viewer-only boxes force-drained. Per-workspace best-effort.
|
|
2332
2404
|
*/
|
|
@@ -2336,8 +2408,7 @@ async function forceDrainOverLimitWorkspaces(
|
|
|
2336
2408
|
workspaceIds: Set<string>,
|
|
2337
2409
|
observability: ActivityServices["observability"],
|
|
2338
2410
|
): Promise<number> {
|
|
2339
|
-
const enforceBalance =
|
|
2340
|
-
settings.billingMode === "stripe" || settings.usageLimitsMode === "managed";
|
|
2411
|
+
const enforceBalance = false;
|
|
2341
2412
|
const cap = settings.sandboxMaxWarmSecondsPerWorkspace;
|
|
2342
2413
|
let forceDrained = 0;
|
|
2343
2414
|
await forEachWithConcurrency(
|
|
@@ -2345,13 +2416,8 @@ async function forceDrainOverLimitWorkspaces(
|
|
|
2345
2416
|
SANDBOX_MAINTENANCE_ITEM_CONCURRENCY,
|
|
2346
2417
|
async (workspaceId) => {
|
|
2347
2418
|
try {
|
|
2348
|
-
const { accountId } = await rlsContextForWorkspace(db, workspaceId);
|
|
2349
|
-
const balance = enforceBalance
|
|
2350
|
-
? await getBillingBalance(db, accountId)
|
|
2351
|
-
: ({ balanceMicros: 1 } as { balanceMicros: number });
|
|
2352
2419
|
const result = await forceDrainOverLimitViewerOnlyBoxes(db, {
|
|
2353
2420
|
workspaceId,
|
|
2354
|
-
balanceMicros: balance.balanceMicros,
|
|
2355
2421
|
enforceBalance,
|
|
2356
2422
|
maxWarmSecondsPerWorkspace: cap,
|
|
2357
2423
|
idleGraceMs: settings.sandboxIdleGraceMs,
|
|
@@ -2939,6 +3005,20 @@ async function terminateDrainableBox(
|
|
|
2939
3005
|
) {
|
|
2940
3006
|
return false;
|
|
2941
3007
|
}
|
|
3008
|
+
// Stamp the charge horizon after verified capture and immediately before
|
|
3009
|
+
// provider stop. Capture can fail and release its claim, allowing re-arm.
|
|
3010
|
+
// A retry after stop success cannot charge post-termination wall time.
|
|
3011
|
+
let stopCutoffMarked = false;
|
|
3012
|
+
const beforeProviderStop = async (): Promise<void> => {
|
|
3013
|
+
const cutoff = await markWarmBillingStopCutoff(db, {
|
|
3014
|
+
accountId,
|
|
3015
|
+
workspaceId: row.workspaceId,
|
|
3016
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3017
|
+
expectedEpoch: row.leaseEpoch,
|
|
3018
|
+
});
|
|
3019
|
+
if (!cutoff) throw new Error("sandbox stop cutoff was fenced before provider termination");
|
|
3020
|
+
stopCutoffMarked = true;
|
|
3021
|
+
};
|
|
2942
3022
|
const termination: ProviderTerminationOutcome | boolean = providerMissingBeforeCapture
|
|
2943
3023
|
? { terminated: true, providerMissingBeforeCapture: true }
|
|
2944
3024
|
: await terminateBox(
|
|
@@ -2962,12 +3042,49 @@ async function terminateDrainableBox(
|
|
|
2962
3042
|
});
|
|
2963
3043
|
},
|
|
2964
3044
|
row.workspaceId,
|
|
3045
|
+
beforeProviderStop,
|
|
2965
3046
|
);
|
|
2966
3047
|
const terminated = typeof termination === "boolean" ? termination : termination.terminated;
|
|
2967
3048
|
if (!terminated) {
|
|
2968
3049
|
return false;
|
|
2969
3050
|
}
|
|
2970
3051
|
|
|
3052
|
+
// Self-hosted/none and an unassigned lease have no provider stop callback;
|
|
3053
|
+
// finish their usage at logical detach without touching customer compute.
|
|
3054
|
+
// A managed instance that was stopped without its callback must fail closed.
|
|
3055
|
+
const providerMissingDuringStop =
|
|
3056
|
+
providerMissingBeforeCapture ||
|
|
3057
|
+
(typeof termination !== "boolean" && termination.providerMissingBeforeCapture);
|
|
3058
|
+
if (!stopCutoffMarked && !providerMissingDuringStop) {
|
|
3059
|
+
if (managedProvider && lease.instanceId) {
|
|
3060
|
+
throw new Error("managed sandbox stopped without a warm usage cutoff");
|
|
3061
|
+
}
|
|
3062
|
+
await beforeProviderStop();
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// Stop was confirmed but the lease is not cold yet. This final epoch-fenced
|
|
3066
|
+
// tick settles the interval since the last heartbeat/reaper sweep, including
|
|
3067
|
+
// a box drained before its first periodic tick. If settlement fails, keep
|
|
3068
|
+
// the draining receipt for retry instead of silently losing the charge.
|
|
3069
|
+
const finalRate =
|
|
3070
|
+
settings.sandboxWarmBillingMode === "usage_only"
|
|
3071
|
+
? 0
|
|
3072
|
+
: sandboxWarmRateMicrosPerSecond(settings, backend);
|
|
3073
|
+
if (!providerMissingDuringStop) {
|
|
3074
|
+
const finalTick = await accrueWarmSeconds(db, {
|
|
3075
|
+
accountId,
|
|
3076
|
+
workspaceId: row.workspaceId,
|
|
3077
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3078
|
+
expectedEpoch: row.leaseEpoch,
|
|
3079
|
+
warmRateMicrosPerSecond: finalRate,
|
|
3080
|
+
billingMode: settings.sandboxWarmBillingMode,
|
|
3081
|
+
finalDrain: true,
|
|
3082
|
+
});
|
|
3083
|
+
if (finalTick.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
|
|
3084
|
+
recordCreditMicros(observability, "usage", finalTick.costMicros);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
|
|
2971
3088
|
const providerMissing =
|
|
2972
3089
|
providerMissingBeforeCapture ||
|
|
2973
3090
|
(typeof termination === "boolean" ? false : termination.providerMissingBeforeCapture);
|
|
@@ -3097,6 +3214,7 @@ export async function terminateProviderBox(
|
|
|
3097
3214
|
diskBackedArchives = false,
|
|
3098
3215
|
releaseFailedCapture?: () => Promise<void>,
|
|
3099
3216
|
workspaceId?: string,
|
|
3217
|
+
beforeProviderStop?: () => Promise<void>,
|
|
3100
3218
|
): Promise<ProviderTerminationOutcome> {
|
|
3101
3219
|
const durableBackendId = (lease.resumeBackendId ?? lease.backend) as string;
|
|
3102
3220
|
const backend = sandboxBackendForSdkBackendId(durableBackendId) ?? durableBackendId;
|
|
@@ -3196,6 +3314,7 @@ export async function terminateProviderBox(
|
|
|
3196
3314
|
return { terminated: false, providerMissingBeforeCapture: false };
|
|
3197
3315
|
}
|
|
3198
3316
|
try {
|
|
3317
|
+
await beforeProviderStop?.();
|
|
3199
3318
|
await terminateModalById(settings, lease.instanceId);
|
|
3200
3319
|
} catch (error) {
|
|
3201
3320
|
if (!isProviderSandboxNotFoundError("modal", error)) {
|
|
@@ -3439,6 +3558,7 @@ export async function terminateProviderBox(
|
|
|
3439
3558
|
throw new Error(`sandbox backend ${backend} returned no terminable session state`);
|
|
3440
3559
|
}
|
|
3441
3560
|
prepareProviderForTeardownAfterCapture(backend, session);
|
|
3561
|
+
await beforeProviderStop?.();
|
|
3442
3562
|
await terminateManagedSandboxSession(client, sessionState, session);
|
|
3443
3563
|
return { terminated: true, providerMissingBeforeCapture: false };
|
|
3444
3564
|
} catch (error) {
|
package/src/sandbox-resume.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
effectiveSandboxLifecycle,
|
|
25
25
|
sandboxArchiveCaptureTimeoutMs,
|
|
26
26
|
sandboxLifecycleTransitionWaitMs,
|
|
27
|
+
sandboxWarmRateMicrosPerSecond,
|
|
27
28
|
type Settings,
|
|
28
29
|
} from "@opengeni/config";
|
|
29
30
|
import { randomUUID } from "node:crypto";
|
|
@@ -1153,6 +1154,10 @@ export async function resumeBoxForTurn(
|
|
|
1153
1154
|
holderId,
|
|
1154
1155
|
subjectId: ids.sessionId,
|
|
1155
1156
|
backend: ids.backend,
|
|
1157
|
+
warmBilling: {
|
|
1158
|
+
mode: settings.sandboxWarmBillingMode,
|
|
1159
|
+
rateMicrosPerSecond: sandboxWarmRateMicrosPerSecond(settings, ids.backend),
|
|
1160
|
+
},
|
|
1156
1161
|
os,
|
|
1157
1162
|
// IMAGE IS SHARED STATE (B3): thread the resolved image so the lease stamps it +
|
|
1158
1163
|
// conflicts on a live box already running a different image. A
|
|
@@ -1283,6 +1288,7 @@ export async function resumeBoxForTurn(
|
|
|
1283
1288
|
holderId,
|
|
1284
1289
|
expectedEpoch: heartbeat.expectedEpoch,
|
|
1285
1290
|
leaseTtlMs: heartbeat.leaseTtlMs,
|
|
1291
|
+
billingMode: services.settings.sandboxWarmBillingMode,
|
|
1286
1292
|
})
|
|
1287
1293
|
.then(async (status) => {
|
|
1288
1294
|
if (heartbeat !== holderLeaseHeartbeat) return;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ActivityCancellationType,
|
|
3
|
+
ActivityFailure,
|
|
4
|
+
ApplicationFailure,
|
|
5
|
+
proxyActivities,
|
|
6
|
+
} from "@temporalio/workflow";
|
|
2
7
|
import type * as activities from "../activities";
|
|
3
8
|
import {
|
|
4
9
|
KNOWLEDGE_SOURCE_SYNC_ACTIVITY_HEARTBEAT_TIMEOUT_MS,
|
|
@@ -160,6 +165,14 @@ export const knowledgeSourceSyncActivity = proxyActivities<
|
|
|
160
165
|
});
|
|
161
166
|
|
|
162
167
|
export function workflowFailureMessage(error: unknown): string {
|
|
168
|
+
if (
|
|
169
|
+
error instanceof ActivityFailure &&
|
|
170
|
+
error.activityType === "runAgentTurn" &&
|
|
171
|
+
error.cause instanceof ApplicationFailure &&
|
|
172
|
+
error.cause.type === "TurnExecutionPolicyDefinitionMismatchError"
|
|
173
|
+
) {
|
|
174
|
+
return error.cause.message;
|
|
175
|
+
}
|
|
163
176
|
if (error instanceof Error) {
|
|
164
177
|
return error.message;
|
|
165
178
|
}
|