@opengeni/worker-bundle 1.0.0-canary.0 → 1.0.0
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-run-admission.d.ts +3 -1
- package/dist/activities/agent-turn/admission.d.ts +24 -3
- package/dist/activities/agent-turn/agent-build.d.ts +4 -0
- package/dist/activities/agent-turn/claude-usage-observer.d.ts +58 -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/codex-plan-entitlement.d.ts +105 -0
- package/dist/activities/agent-turn/codex.d.ts +3 -2
- package/dist/activities/agent-turn/compaction-prep.d.ts +21 -5
- package/dist/activities/agent-turn/credential-leases.d.ts +19 -33
- package/dist/activities/agent-turn/credential-restriction.d.ts +3 -0
- package/dist/activities/agent-turn/errors.d.ts +82 -11
- package/dist/activities/agent-turn/failure-settlement.d.ts +6 -3
- package/dist/activities/agent-turn/final-reply.d.ts +18 -0
- package/dist/activities/agent-turn/finalization-monitor.d.ts +17 -0
- package/dist/activities/agent-turn/finalization.d.ts +4 -2
- package/dist/activities/agent-turn/github-repository-bindings.d.ts +2 -0
- package/dist/activities/agent-turn/governance-model.d.ts +9 -2
- package/dist/activities/agent-turn/history-sink.d.ts +2 -0
- package/dist/activities/agent-turn/history.d.ts +2 -0
- package/dist/activities/agent-turn/input-wait-reply.d.ts +41 -0
- package/dist/activities/agent-turn/media-artifacts.d.ts +5 -3
- package/dist/activities/agent-turn/model-call-admission.d.ts +15 -0
- package/dist/activities/agent-turn/model-usage.d.ts +6 -0
- package/dist/activities/agent-turn/model-wire-dispatch.d.ts +15 -0
- package/dist/activities/agent-turn/optional-repositories.d.ts +59 -0
- package/dist/activities/agent-turn/provider-dispatch-barrier.d.ts +12 -1
- package/dist/activities/agent-turn/quiescence.d.ts +7 -7
- package/dist/activities/agent-turn/recovery-warning.d.ts +9 -0
- package/dist/activities/agent-turn/refresh-credentials.d.ts +6 -0
- package/dist/activities/agent-turn/run-credentials.d.ts +8 -3
- package/dist/activities/agent-turn/sandbox-establish.d.ts +2 -1
- package/dist/activities/agent-turn/sandbox-provision.d.ts +3 -1
- package/dist/activities/agent-turn/sandbox-route.d.ts +3 -20
- package/dist/activities/agent-turn/sandbox-runtime.d.ts +2 -2
- package/dist/activities/agent-turn/session-title.d.ts +34 -2
- package/dist/activities/agent-turn/skill-checkout.d.ts +25 -6
- package/dist/activities/agent-turn/skill-read.d.ts +70 -2
- package/dist/activities/agent-turn/skill-remove.d.ts +14 -0
- package/dist/activities/agent-turn/skill-search.d.ts +3 -1
- package/dist/activities/agent-turn/skill-tools.d.ts +15 -2
- package/dist/activities/agent-turn/skill-transfer.d.ts +18 -6
- package/dist/activities/agent-turn/stream-attempt.d.ts +7 -3
- package/dist/activities/agent-turn/subscription-lease.d.ts +38 -0
- package/dist/activities/agent-turn/tool-environment.d.ts +56 -6
- package/dist/activities/agent-turn/tool-policy.d.ts +43 -0
- package/dist/activities/agent-turn/turn-context.d.ts +29 -0
- package/dist/activities/agent-turn/turn-sandbox-access.d.ts +3 -0
- package/dist/activities/agent-turn/xai-capacity.d.ts +2 -1
- package/dist/activities/automations.d.ts +3 -1
- package/dist/activities/browser-deadline-checkpoint.d.ts +29 -0
- package/dist/activities/codemode-dispatcher.d.ts +9 -2
- package/dist/activities/codex-capacity.d.ts +18 -2
- package/dist/activities/codex-rotation.d.ts +7 -1
- package/dist/activities/context-compaction.d.ts +16 -2
- package/dist/activities/gmail-files.d.ts +32 -0
- package/dist/activities/goals.d.ts +8 -0
- package/dist/activities/knowledge-indexing.d.ts +19 -0
- package/dist/activities/mcp-account-routes.d.ts +3 -0
- package/dist/activities/mcp-credentials.d.ts +12 -13
- package/dist/activities/mcp-operation-authority.d.ts +0 -5
- package/dist/activities/model-call-fact-reconciler.d.ts +34 -0
- package/dist/activities/open-suffix-resume.d.ts +13 -0
- package/dist/activities/parent-wake.d.ts +6 -12
- package/dist/activities/retained-screenshots.d.ts +7 -2
- package/dist/activities/rig-verification.d.ts +2 -3
- package/dist/activities/run-credential-renewal.d.ts +1 -0
- package/dist/activities/run-credentials.d.ts +15 -3
- package/dist/activities/sandbox-images.d.ts +1 -1
- package/dist/activities/sandbox-lease.d.ts +47 -9
- package/dist/activities/scheduled-tasks.d.ts +4 -35
- package/dist/activities/session-state.d.ts +70 -3
- package/dist/activities/types.d.ts +55 -6
- package/dist/activities/workspace-capture.d.ts +2 -0
- package/dist/activities/workspace-credential-provider.d.ts +46 -0
- package/dist/{activities-control-HMFGSKC5.js → activities-control-BNPTRTOE.js} +1896 -418
- package/dist/activities-control-BNPTRTOE.js.map +1 -0
- package/dist/activities-control.d.ts +182 -4
- package/dist/{activities-turn-AP67JADS.js → activities-turn-5LGZOSVU.js} +4476 -1327
- package/dist/activities-turn-5LGZOSVU.js.map +1 -0
- package/dist/activities.d.ts +182 -4
- package/dist/artifact-materializer-entry.js +5 -2
- package/dist/artifact-materializer-entry.js.map +1 -1
- package/dist/artifact-outbox-entry.js +5 -2
- package/dist/artifact-outbox-entry.js.map +1 -1
- package/dist/browser-deadline-checkpoint-contract.d.ts +2 -0
- package/dist/{chunk-A4XQWVZK.js → chunk-3ZTRWDMN.js} +307 -4
- package/dist/chunk-3ZTRWDMN.js.map +1 -0
- package/dist/{chunk-RAMNYSAW.js → chunk-4ER3YXOT.js} +22 -4
- package/dist/chunk-4ER3YXOT.js.map +1 -0
- package/dist/{chunk-CYITU5UH.js → chunk-LJX7Z2KP.js} +1445 -343
- package/dist/chunk-LJX7Z2KP.js.map +1 -0
- package/dist/editable-artifact-materializer-service.d.ts +2 -2
- package/dist/editable-artifact-outbox-service.d.ts +2 -2
- package/dist/host-export-pump.d.ts +3 -1
- package/dist/host-export.d.ts +1 -1
- package/dist/host-export.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +95 -3
- package/dist/index.js +137 -43
- package/dist/index.js.map +1 -1
- package/dist/observability-metrics.d.ts +118 -8
- package/dist/retained-process-diagnostics.d.ts +4 -0
- package/dist/retained-process-retry.d.ts +7 -0
- package/dist/sandbox-resume.d.ts +55 -13
- package/dist/sandbox-routing.d.ts +5 -3
- package/dist/sandbox-snapshot-diagnostics.d.ts +20 -0
- package/dist/session-event-fanout.d.ts +6 -0
- package/dist/warm-capture-holder.d.ts +3 -0
- package/dist/worker-service-lifecycle.d.ts +12 -0
- package/dist/workflow-bundle.js +273 -41
- package/dist/workflows/activities.d.ts +1 -1
- package/dist/workflows/browser-deadline-checkpoint.d.ts +9 -0
- package/dist/workflows/scheduled-tasks.d.ts +4 -0
- package/dist/workflows/session.d.ts +36 -0
- package/dist/workflows.d.ts +1 -0
- package/package.json +21 -19
- package/src/activities/agent-run-admission.ts +17 -1
- package/src/activities/agent-turn/admission.ts +66 -3
- package/src/activities/agent-turn/agent-build.ts +84 -10
- package/src/activities/agent-turn/claim.ts +186 -43
- package/src/activities/agent-turn/claude-usage-observer.ts +221 -0
- package/src/activities/agent-turn/code-search.ts +275 -0
- package/src/activities/agent-turn/codex-capacity.ts +172 -8
- package/src/activities/agent-turn/codex-plan-entitlement.ts +254 -0
- package/src/activities/agent-turn/codex.ts +13 -5
- package/src/activities/agent-turn/compaction-prep.ts +170 -102
- package/src/activities/agent-turn/credential-leases.ts +122 -216
- package/src/activities/agent-turn/credential-restriction.ts +14 -0
- package/src/activities/agent-turn/errors.ts +647 -48
- package/src/activities/agent-turn/failure-settlement.ts +682 -130
- package/src/activities/agent-turn/final-reply.ts +46 -0
- package/src/activities/agent-turn/finalization-monitor.ts +120 -0
- package/src/activities/agent-turn/finalization.ts +146 -43
- package/src/activities/agent-turn/github-repository-bindings.ts +3 -1
- package/src/activities/agent-turn/governance-model.ts +69 -28
- package/src/activities/agent-turn/history-sink.ts +16 -2
- package/src/activities/agent-turn/history.ts +38 -11
- package/src/activities/agent-turn/input-wait-reply.ts +103 -0
- package/src/activities/agent-turn/knowledge-source-tools.ts +2 -0
- package/src/activities/agent-turn/media-artifacts.ts +2 -1
- package/src/activities/agent-turn/model-call-admission.ts +94 -0
- package/src/activities/agent-turn/model-usage.ts +64 -3
- package/src/activities/agent-turn/model-wire-dispatch.ts +27 -0
- package/src/activities/agent-turn/optional-repositories.ts +224 -0
- package/src/activities/agent-turn/provider-dispatch-barrier.ts +22 -0
- package/src/activities/agent-turn/quiescence.ts +10 -7
- package/src/activities/agent-turn/recovery-warning.ts +16 -0
- package/src/activities/agent-turn/refresh-credentials.ts +44 -0
- package/src/activities/agent-turn/run-credentials.ts +116 -55
- package/src/activities/agent-turn/run.ts +320 -93
- package/src/activities/agent-turn/sandbox-establish.ts +84 -8
- package/src/activities/agent-turn/sandbox-provision.ts +3 -1
- package/src/activities/agent-turn/sandbox-route.ts +4 -21
- package/src/activities/agent-turn/sandbox-runtime.ts +343 -18
- package/src/activities/agent-turn/session-title.ts +81 -5
- package/src/activities/agent-turn/skill-checkout.ts +111 -24
- package/src/activities/agent-turn/skill-install.ts +1 -1
- package/src/activities/agent-turn/skill-read.ts +374 -28
- package/src/activities/agent-turn/skill-remove.ts +46 -0
- package/src/activities/agent-turn/skill-save.ts +1 -1
- package/src/activities/agent-turn/skill-search.ts +10 -1
- package/src/activities/agent-turn/skill-selection.ts +4 -0
- package/src/activities/agent-turn/skill-tools.ts +112 -18
- package/src/activities/agent-turn/skill-transfer.ts +68 -23
- package/src/activities/agent-turn/stream-attempt.ts +313 -77
- package/src/activities/agent-turn/subscription-lease.ts +115 -0
- package/src/activities/agent-turn/tool-environment.ts +316 -167
- package/src/activities/agent-turn/tool-policy.ts +80 -1
- package/src/activities/agent-turn/turn-context.ts +35 -0
- package/src/activities/agent-turn/turn-sandbox-access.ts +7 -1
- package/src/activities/agent-turn/xai-capacity.ts +94 -51
- package/src/activities/automations.ts +76 -4
- package/src/activities/browser-deadline-checkpoint.ts +279 -0
- package/src/activities/capabilities.ts +108 -11
- package/src/activities/codemode-dispatcher.ts +36 -8
- package/src/activities/codex-capacity.ts +60 -25
- package/src/activities/codex-rotation.ts +39 -17
- package/src/activities/context-compaction.ts +75 -18
- package/src/activities/gmail-files.ts +88 -0
- package/src/activities/goals.ts +116 -121
- package/src/activities/google-drive-publication.ts +1 -3
- package/src/activities/image-generation-operation.ts +1 -1
- package/src/activities/knowledge-indexing.ts +269 -15
- package/src/activities/knowledge-source-sync.ts +10 -0
- package/src/activities/mcp-account-routes.ts +17 -0
- package/src/activities/mcp-credentials.ts +83 -194
- package/src/activities/mcp-operation-authority.ts +16 -41
- package/src/activities/model-call-fact-reconciler.ts +112 -0
- package/src/activities/open-suffix-resume.ts +15 -2
- package/src/activities/parent-wake.ts +46 -21
- package/src/activities/retained-screenshots.ts +82 -11
- package/src/activities/rig-verification.ts +38 -37
- package/src/activities/run-credential-renewal.ts +13 -1
- package/src/activities/run-credentials.ts +59 -17
- package/src/activities/run-input.ts +25 -16
- package/src/activities/sandbox-images.ts +0 -1
- package/src/activities/sandbox-lease.ts +798 -82
- package/src/activities/scheduled-tasks.ts +550 -178
- package/src/activities/session-state.ts +245 -6
- package/src/activities/site-auth-maintenance.ts +3 -0
- package/src/activities/types.ts +72 -5
- package/src/activities/workflow-wake.ts +2 -17
- package/src/activities/workspace-capture.ts +13 -2
- package/src/activities/workspace-credential-provider.ts +283 -0
- package/src/activities-control.ts +4 -0
- package/src/activities-turn.ts +5 -0
- package/src/activities.ts +13 -0
- package/src/activity-services.ts +16 -4
- package/src/browser-deadline-checkpoint-contract.ts +5 -0
- package/src/editable-artifact-hint-broker.ts +3 -0
- package/src/editable-artifact-materializer-service.ts +7 -3
- package/src/editable-artifact-outbox-service.ts +4 -4
- package/src/github-rest-mcp.ts +1 -2
- package/src/host-export-pump.ts +26 -4
- package/src/host-export.ts +3 -0
- package/src/http.ts +4 -2
- package/src/index.ts +99 -37
- package/src/observability-metrics.ts +510 -5
- package/src/personal-github-git-credentials.ts +2 -1
- package/src/pr-review-credentials.ts +1 -3
- package/src/retained-process-diagnostics.ts +50 -0
- package/src/retained-process-retry.ts +36 -0
- package/src/sandbox-resume.ts +388 -101
- package/src/sandbox-routing.ts +150 -25
- package/src/sandbox-snapshot-diagnostics.ts +112 -0
- package/src/session-event-fanout.ts +23 -0
- package/src/warm-capture-holder.ts +15 -0
- package/src/worker-service-lifecycle.ts +53 -0
- package/src/workflows/activities.ts +16 -1
- package/src/workflows/browser-deadline-checkpoint.ts +50 -0
- package/src/workflows/file-upload-reaper.ts +2 -0
- package/src/workflows/scheduled-tasks.ts +26 -13
- package/src/workflows/session.ts +242 -22
- package/src/workflows.ts +4 -0
- package/dist/activities/agent-turn/knowledge-sources.d.ts +0 -20
- package/dist/activities/packs.d.ts +0 -55
- package/dist/activities-control-HMFGSKC5.js.map +0 -1
- package/dist/activities-turn-AP67JADS.js.map +0 -1
- package/dist/chunk-A4XQWVZK.js.map +0 -1
- package/dist/chunk-CYITU5UH.js.map +0 -1
- package/dist/chunk-RAMNYSAW.js.map +0 -1
- package/src/activities/agent-turn/knowledge-sources.ts +0 -86
- package/src/activities/packs.ts +0 -166
|
@@ -13,14 +13,19 @@
|
|
|
13
13
|
// harness and executes admitted drains concurrently. There is no per-session
|
|
14
14
|
// timer, viewer activity, owner task queue, or provider-specific lifecycle path
|
|
15
15
|
// in the normal drain state machine.
|
|
16
|
+
import { warnDrainSnapshotFailure } from "../sandbox-snapshot-diagnostics";
|
|
17
|
+
import { publishDurableSessionEvents } from "../session-event-fanout";
|
|
18
|
+
import { warnRetainedProcessProofFailure } from "../retained-process-diagnostics";
|
|
19
|
+
import { retainedProcessDeadlineRetryMs } from "../retained-process-retry";
|
|
16
20
|
|
|
17
21
|
import { createHash, randomUUID } from "node:crypto";
|
|
18
|
-
import {
|
|
22
|
+
import { requestRetainedProcessDeadlineCancellation } from "@opengeni/db/retained-provider-commands";
|
|
19
23
|
import type { ProviderCommandPersistence, ProviderCommandSession } from "@opengeni/runtime";
|
|
20
24
|
import { Context } from "@temporalio/activity";
|
|
21
25
|
import { OpLostReason, OpState, type OpStatus } from "@opengeni/agent-proto";
|
|
22
26
|
import {
|
|
23
27
|
accrueWarmSeconds,
|
|
28
|
+
retainedProviderCommandPersistence,
|
|
24
29
|
adoptLegacyModalCheckpointArtifact,
|
|
25
30
|
confirmDrainCold,
|
|
26
31
|
appendSessionEventToSandboxGroup,
|
|
@@ -36,10 +41,17 @@ import {
|
|
|
36
41
|
countSandboxLeasesByLiveness,
|
|
37
42
|
deferRetainedProcessReconciliation,
|
|
38
43
|
forceDrainOverLimitViewerOnlyBoxes,
|
|
39
|
-
getBillingBalance,
|
|
40
44
|
listCreditBalancesByAccount,
|
|
45
|
+
countActiveUsers,
|
|
46
|
+
readCreditGrantTotals,
|
|
47
|
+
readManagedAuthNewSignupsSwitch,
|
|
48
|
+
readVerifiedSignupTrialSwitch,
|
|
41
49
|
listLegacyModalCheckpointSlots,
|
|
42
50
|
listLiveModalSandboxLeaseAttributions,
|
|
51
|
+
listPendingModalProviderCreates,
|
|
52
|
+
claimModalProviderCreateRecovery,
|
|
53
|
+
recordRecoveredModalProviderCreate,
|
|
54
|
+
markWarmBillingStopCutoff,
|
|
43
55
|
listMeterableWarmLeases,
|
|
44
56
|
listSandboxViewerForceDrainWorkspaceIds,
|
|
45
57
|
markSandboxCheckpointArtifactDeletePending,
|
|
@@ -49,13 +61,16 @@ import {
|
|
|
49
61
|
recordRetainedProcessReconciliationProof,
|
|
50
62
|
replaceWorkspaceArchiveCaptureAfterProof,
|
|
51
63
|
readLease,
|
|
64
|
+
readWorkspaceArchiveCapturePreflight,
|
|
52
65
|
reapExpiredSessionListSnapshots,
|
|
53
66
|
reapStaleLeaseHoldersGlobal,
|
|
54
67
|
requestDueSandboxRotationsGlobal,
|
|
55
68
|
readSandboxRotationBacklog,
|
|
69
|
+
readRecentSandboxRecoveryObservations,
|
|
56
70
|
workspaceArchiveCaptureDeadlineElapsed,
|
|
57
71
|
retainedProcessReconciliationProof,
|
|
58
72
|
retainedProcessSettlementIdentity,
|
|
73
|
+
recoverManagedSessionBackgroundCommand,
|
|
59
74
|
settleClaimedConnectedMachineBackgroundCommand,
|
|
60
75
|
settleSandboxCheckpointArtifactGc,
|
|
61
76
|
rlsContextForWorkspace,
|
|
@@ -67,9 +82,12 @@ import {
|
|
|
67
82
|
type LeaseSnapshot,
|
|
68
83
|
} from "@opengeni/db";
|
|
69
84
|
import {
|
|
85
|
+
claimConnectedCommandOutputReleases,
|
|
70
86
|
claimConnectedMachineSessionBackgroundCommands,
|
|
71
87
|
deferConnectedMachineBackgroundCommandReconciliation,
|
|
72
88
|
recordConnectedMachineBackgroundCommandProof,
|
|
89
|
+
recordConnectedCommandOutputConsumption,
|
|
90
|
+
settleConnectedCommandOutputReleaseClaim,
|
|
73
91
|
type ConnectedMachineBackgroundCommandClaim,
|
|
74
92
|
type ConnectedMachineBackgroundCommandProof,
|
|
75
93
|
} from "@opengeni/db/session-background-commands";
|
|
@@ -107,6 +125,7 @@ import {
|
|
|
107
125
|
providerWorkspaceCapturePolicy,
|
|
108
126
|
resolveModalCheckpointProviderBindingForLiveSandbox,
|
|
109
127
|
resolveModalCheckpointProviderBinding,
|
|
128
|
+
findModalProviderCreateReceipt,
|
|
110
129
|
resolveModalCheckpointProviderBindingForSession,
|
|
111
130
|
querySelfhostedOp,
|
|
112
131
|
resumeExactSandboxSession,
|
|
@@ -139,7 +158,9 @@ import { assertSandboxDrainInputTiming, sandboxDrainTiming } from "../sandbox-re
|
|
|
139
158
|
import type { ControlActivityServices as ActivityServices } from "./types";
|
|
140
159
|
import { reconcilePendingParentSystemUpdates } from "./parent-wake";
|
|
141
160
|
import {
|
|
161
|
+
recordActiveUserGauges,
|
|
142
162
|
recordCreditBalanceGauges,
|
|
163
|
+
recordCreditGrantGauges,
|
|
143
164
|
recordCreditMicros,
|
|
144
165
|
recordExpiredDrainingSandboxLeaseGauges,
|
|
145
166
|
recordOpenSandboxKubernetesInventoryGauges,
|
|
@@ -154,8 +175,14 @@ import {
|
|
|
154
175
|
type SandboxInventoryProjectionDomain,
|
|
155
176
|
recordSandboxLeaseGauges,
|
|
156
177
|
recordSandboxOrphansTerminated,
|
|
178
|
+
recordSandboxCommandContainment,
|
|
179
|
+
recordSandboxProviderMissingBeforeCapture,
|
|
180
|
+
recordSandboxRecoveryObservationGauges,
|
|
157
181
|
recordSandboxRotationBacklogGauges,
|
|
158
182
|
recordTurnsQueuedGauge,
|
|
183
|
+
recordVerifiedSignupTrialDeploymentFlagGauge,
|
|
184
|
+
recordManagedAuthNewSignupsSwitchGauge,
|
|
185
|
+
recordVerifiedSignupTrialSwitchGauge,
|
|
159
186
|
runtimeMetricsHooksForObservability,
|
|
160
187
|
} from "../observability-metrics";
|
|
161
188
|
import {
|
|
@@ -182,8 +209,7 @@ export type ReapSandboxLeasesResult = {
|
|
|
182
209
|
skipped: number;
|
|
183
210
|
/** Warm viewer-only leases that accrued warm-seconds this tick (P2.1). */
|
|
184
211
|
metered: number;
|
|
185
|
-
/**
|
|
186
|
-
* (0 balance / over the warm cap) — turn-held boxes are never drained (P2.1). */
|
|
212
|
+
/** Holderless boxes accelerated to draining by the optional warm cap. */
|
|
187
213
|
forceDrained: number;
|
|
188
214
|
/** Provider-side Modal orphan sandboxes terminated by the defensive sweep. */
|
|
189
215
|
modalOrphansTerminated: number;
|
|
@@ -251,6 +277,8 @@ export type TerminateBoxFn = (
|
|
|
251
277
|
capturePolicy?: ProviderWorkspaceCapturePolicy | null,
|
|
252
278
|
diskBackedArchives?: boolean,
|
|
253
279
|
releaseFailedCapture?: () => Promise<void>,
|
|
280
|
+
workspaceId?: string,
|
|
281
|
+
beforeProviderStop?: () => Promise<void>,
|
|
254
282
|
) => Promise<boolean | ProviderTerminationOutcome>;
|
|
255
283
|
|
|
256
284
|
export type SweepModalOrphansFn = (
|
|
@@ -270,6 +298,8 @@ export type SandboxLeaseActivityOptions = {
|
|
|
270
298
|
/** Override the provider-side Modal orphan sweep (tests spy this; defaults to
|
|
271
299
|
* Modal list+tag comparison when Modal is configured). */
|
|
272
300
|
sweepModalOrphans?: SweepModalOrphansFn;
|
|
301
|
+
/** Only provider discovery is injected; durable recovery remains real. */
|
|
302
|
+
findModalProviderCreateReceipt?: typeof findModalProviderCreateReceipt;
|
|
273
303
|
/** Override the read-only bounded OpenSandbox Kubernetes projection. */
|
|
274
304
|
inspectOpenSandboxKubernetesInventory?: InspectOpenSandboxKubernetesInventoryFn;
|
|
275
305
|
/** Override only the read-only provider process probe. The canonical DB
|
|
@@ -303,6 +333,8 @@ export type RetainedProcessProbeResult =
|
|
|
303
333
|
| "provider_binding_missing"
|
|
304
334
|
| "provider_binding_mismatch"
|
|
305
335
|
| "process_observation_unavailable";
|
|
336
|
+
/** A running command whose last read still returned a large backlog. */
|
|
337
|
+
outputBacklog?: boolean;
|
|
306
338
|
};
|
|
307
339
|
|
|
308
340
|
export type RetainedProcessProbeFn = (
|
|
@@ -317,8 +349,12 @@ export type RetainedProcessProbeFn = (
|
|
|
317
349
|
streamFidelity?: "separate" | "merged",
|
|
318
350
|
) => Promise<void>,
|
|
319
351
|
providerPersistence?: ProviderCommandPersistence,
|
|
352
|
+
drainBudget?: RetainedProcessDrainBudget,
|
|
320
353
|
) => Promise<RetainedProcessProbeResult>;
|
|
321
354
|
|
|
355
|
+
/** Shared wall-clock bound on output draining within one reconciliation sweep. */
|
|
356
|
+
export type RetainedProcessDrainBudget = { until: number };
|
|
357
|
+
|
|
322
358
|
export type HistoricalModalSandboxLifecycleProbeFn = typeof inspectModalSandboxLifecycle;
|
|
323
359
|
|
|
324
360
|
export type DrainableProviderProbeFn = (
|
|
@@ -329,6 +365,16 @@ export type DrainableProviderProbeFn = (
|
|
|
329
365
|
export const RETAINED_PROCESS_RECONCILIATION_LIMIT = 20;
|
|
330
366
|
export const RETAINED_PROCESS_RECONCILIATION_CLAIM_TTL_MS = 5 * 60_000;
|
|
331
367
|
export const RETAINED_PROCESS_PROVIDER_PROBE_TIMEOUT_MS = 5_000;
|
|
368
|
+
/** Bounds on reading a command's output backlog: per claim, and across one
|
|
369
|
+
* reconciliation sweep so the reaper activity stays well inside its timeout.
|
|
370
|
+
* Each read returns up to one provider page per stream. */
|
|
371
|
+
export const RETAINED_PROCESS_OUTPUT_DRAIN_MAX_READS = 8;
|
|
372
|
+
export const RETAINED_PROCESS_OUTPUT_DRAIN_BUDGET_MS = 10_000;
|
|
373
|
+
export const RETAINED_PROCESS_OUTPUT_DRAIN_SWEEP_BUDGET_MS = 60_000;
|
|
374
|
+
/** A live process whose read returned at least this much output left a backlog
|
|
375
|
+
* behind it. A server or watcher that only trickles output stays on ordinary
|
|
376
|
+
* backoff; a process the provider reports exited is always read to EOF. */
|
|
377
|
+
export const RETAINED_PROCESS_OUTPUT_BACKLOG_BYTES = 256 * 1024;
|
|
332
378
|
export const RETAINED_PROCESS_BINDING_QUARANTINE_AFTER_ATTEMPTS = 5;
|
|
333
379
|
export const RETAINED_PROCESS_BINDING_QUARANTINE_RETRY_MS = 24 * 60 * 60_000;
|
|
334
380
|
export const CONNECTED_COMMAND_RECONCILIATION_LIMIT = 20;
|
|
@@ -477,7 +523,14 @@ export function createSandboxLeaseActivities(
|
|
|
477
523
|
options: SandboxLeaseActivityOptions = {},
|
|
478
524
|
) {
|
|
479
525
|
const terminateBox: TerminateBoxFn =
|
|
480
|
-
options.terminateBox
|
|
526
|
+
(options.terminateBox
|
|
527
|
+
? async (...args: Parameters<TerminateBoxFn>) => {
|
|
528
|
+
// The injected test seam represents the stop itself; the real
|
|
529
|
+
// implementation invokes this only after capture has succeeded.
|
|
530
|
+
await args[10]?.();
|
|
531
|
+
return options.terminateBox!(...args);
|
|
532
|
+
}
|
|
533
|
+
: null) ??
|
|
481
534
|
(async (
|
|
482
535
|
settings,
|
|
483
536
|
lease,
|
|
@@ -488,6 +541,8 @@ export function createSandboxLeaseActivities(
|
|
|
488
541
|
capturePolicy,
|
|
489
542
|
diskBackedArchives,
|
|
490
543
|
releaseFailedCapture,
|
|
544
|
+
workspaceId,
|
|
545
|
+
beforeProviderStop,
|
|
491
546
|
) =>
|
|
492
547
|
await terminateProviderBox(
|
|
493
548
|
settings,
|
|
@@ -501,6 +556,8 @@ export function createSandboxLeaseActivities(
|
|
|
501
556
|
capturePolicy,
|
|
502
557
|
diskBackedArchives,
|
|
503
558
|
releaseFailedCapture,
|
|
559
|
+
workspaceId,
|
|
560
|
+
beforeProviderStop,
|
|
504
561
|
));
|
|
505
562
|
const sweepModalOrphans: SweepModalOrphansFn =
|
|
506
563
|
options.sweepModalOrphans ?? sweepModalOrphansForConfiguredBackend;
|
|
@@ -513,10 +570,15 @@ export function createSandboxLeaseActivities(
|
|
|
513
570
|
try {
|
|
514
571
|
const { db, settings, observability } = await services();
|
|
515
572
|
const timing = sandboxDrainTiming(settings);
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
573
|
+
const commandContainment = {
|
|
574
|
+
idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
|
|
575
|
+
onCommandContainment: (outcome: Parameters<typeof recordSandboxCommandContainment>[1]) =>
|
|
576
|
+
recordSandboxCommandContainment(observability, outcome),
|
|
577
|
+
onCommandContainmentError: (error: unknown) => {
|
|
578
|
+
observability.warn("sandbox reaper: retained command containment inspection failed", {
|
|
579
|
+
error: error instanceof Error ? error.message : String(error),
|
|
580
|
+
});
|
|
581
|
+
},
|
|
520
582
|
};
|
|
521
583
|
if (!settings.sandboxOwnershipEnabled) {
|
|
522
584
|
// Turns skip leases when the flag is off, but Computer/Browser attach
|
|
@@ -524,7 +586,7 @@ export function createSandboxLeaseActivities(
|
|
|
524
586
|
// behind rotation_in_progress forever. Inventory and drain those rows;
|
|
525
587
|
// do not request NEW deadline rotations or meter warm time.
|
|
526
588
|
const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
|
|
527
|
-
|
|
589
|
+
...commandContainment,
|
|
528
590
|
viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
|
|
529
591
|
turnHolderTtlMs: settings.sandboxLeaseTtlMs,
|
|
530
592
|
interactionHolderTtlMs: settings.sandboxInteractionHolderTtlMs,
|
|
@@ -561,7 +623,7 @@ export function createSandboxLeaseActivities(
|
|
|
561
623
|
// Billing, reconciliation, provider-orphan cleanup, artifact GC, and gauges
|
|
562
624
|
// run only after every drainable box has its own durable child.
|
|
563
625
|
const drainableInventory = await reapStaleLeaseHoldersGlobal(db, {
|
|
564
|
-
|
|
626
|
+
...commandContainment,
|
|
565
627
|
viewerHolderTtlMs: settings.sandboxViewerHolderTtlMs,
|
|
566
628
|
// Dead-worker turn holders: a live holder is touched every 10s from the
|
|
567
629
|
// moment it is registered (resumeBoxForTurn's holder-liveness loop covers
|
|
@@ -605,7 +667,7 @@ export function createSandboxLeaseActivities(
|
|
|
605
667
|
instanceId: input.target.instanceId,
|
|
606
668
|
});
|
|
607
669
|
try {
|
|
608
|
-
const { db, settings, observability, objectStorage } = await services();
|
|
670
|
+
const { db, settings, observability, objectStorage, bus } = await services();
|
|
609
671
|
assertSandboxDrainInputTiming(input);
|
|
610
672
|
const drainSettings =
|
|
611
673
|
settings.sandboxSnapshotTimeoutMs === input.snapshotTimeoutMs
|
|
@@ -629,6 +691,7 @@ export function createSandboxLeaseActivities(
|
|
|
629
691
|
probeDrainableProvider,
|
|
630
692
|
captureAttempt,
|
|
631
693
|
objectStorage,
|
|
694
|
+
bus,
|
|
632
695
|
);
|
|
633
696
|
return { status: drainedCold ? "terminated" : "skipped" };
|
|
634
697
|
} catch (error) {
|
|
@@ -703,6 +766,23 @@ export function createSandboxLeaseActivities(
|
|
|
703
766
|
// immutable launch locators must reconcile even when managed ownership is
|
|
704
767
|
// disabled, otherwise an exact runner exit/loss proof can remain stranded.
|
|
705
768
|
await reconcileConnectedMachineBackgroundCommands(db, settings, observability, service.bus);
|
|
769
|
+
await reconcileConnectedCommandOutputReleases(db, settings, observability, service.bus);
|
|
770
|
+
|
|
771
|
+
// Disabling new lease ownership cannot strand a previously dispatched
|
|
772
|
+
// operation. This only attributes a positive receipt; normal draining
|
|
773
|
+
// already runs in both ownership modes.
|
|
774
|
+
try {
|
|
775
|
+
await reconcileModalProviderCreates(
|
|
776
|
+
db,
|
|
777
|
+
settings,
|
|
778
|
+
observability,
|
|
779
|
+
options.findModalProviderCreateReceipt,
|
|
780
|
+
);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
observability.warn("sandbox reaper: Modal create recovery inventory failed", {
|
|
783
|
+
error: error instanceof Error ? error.message : String(error),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
706
786
|
|
|
707
787
|
if (!settings.sandboxOwnershipEnabled) {
|
|
708
788
|
// Inventory remains useful while provider ownership is intentionally
|
|
@@ -728,8 +808,7 @@ export function createSandboxLeaseActivities(
|
|
|
728
808
|
metered = meterResult.accrued;
|
|
729
809
|
const forceDrainWorkspaceIds = new Set<string>();
|
|
730
810
|
if (
|
|
731
|
-
settings.
|
|
732
|
-
settings.usageLimitsMode === "managed" ||
|
|
811
|
+
settings.sandboxWarmBillingMode === "credits" ||
|
|
733
812
|
settings.sandboxMaxWarmSecondsPerWorkspace > 0
|
|
734
813
|
) {
|
|
735
814
|
for (const workspaceId of meterResult.workspaceIds) {
|
|
@@ -903,6 +982,9 @@ type ConnectedCommandReconciliationOutcome =
|
|
|
903
982
|
| "proof_checkpoint_failed"
|
|
904
983
|
| "settled_exited"
|
|
905
984
|
| "settled_lost"
|
|
985
|
+
| "output_unavailable"
|
|
986
|
+
| "output_ack_published"
|
|
987
|
+
| "output_released"
|
|
906
988
|
| "settlement_failed"
|
|
907
989
|
| "defer_failed";
|
|
908
990
|
|
|
@@ -1103,6 +1185,7 @@ export async function reconcileConnectedMachineBackgroundCommands(
|
|
|
1103
1185
|
);
|
|
1104
1186
|
await forEachWithConcurrency(claims, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (claim) => {
|
|
1105
1187
|
let proof = claim.proof;
|
|
1188
|
+
let outputReplay: Awaited<ReturnType<OpStreamExecClient["readExisting"]>> | null = null;
|
|
1106
1189
|
const connectionKey = JSON.stringify([
|
|
1107
1190
|
claim.controlWorkspaceId,
|
|
1108
1191
|
claim.enrollmentId,
|
|
@@ -1160,18 +1243,14 @@ export async function reconcileConnectedMachineBackgroundCommands(
|
|
|
1160
1243
|
retryClock: defaultSelfhostedRetryClock,
|
|
1161
1244
|
journal: { attachGeneration: () => String(Date.now()), persistSettled: () => {} },
|
|
1162
1245
|
});
|
|
1163
|
-
|
|
1246
|
+
outputReplay = await replayConnectedCommandOutput(
|
|
1247
|
+
client,
|
|
1164
1248
|
claim.opId,
|
|
1165
|
-
proof
|
|
1249
|
+
Boolean(proof),
|
|
1166
1250
|
async (frames) => {
|
|
1167
1251
|
await captureConnectedCommandOutput(db, claim, bus)(claim.commandId, frames);
|
|
1168
1252
|
},
|
|
1169
1253
|
);
|
|
1170
|
-
if (proof && replay.status !== "completed" && !replay.terminal) {
|
|
1171
|
-
throw new Error(
|
|
1172
|
-
"Connected command terminal output replay has not reached its exit frontier",
|
|
1173
|
-
);
|
|
1174
|
-
}
|
|
1175
1254
|
}
|
|
1176
1255
|
if (!proof) {
|
|
1177
1256
|
await deferConnectedCommandClaim(
|
|
@@ -1226,6 +1305,18 @@ export async function reconcileConnectedMachineBackgroundCommands(
|
|
|
1226
1305
|
if (!settlement.settled) {
|
|
1227
1306
|
throw new Error("Connected command settlement lost its exact claim or proof");
|
|
1228
1307
|
}
|
|
1308
|
+
const receipt =
|
|
1309
|
+
outputReplay?.outputReceipt ??
|
|
1310
|
+
(outputReplay?.status === "running" ? outputReplay.terminal?.outputReceipt : undefined);
|
|
1311
|
+
const terminal =
|
|
1312
|
+
outputReplay?.status === "completed" ? outputReplay : outputReplay?.terminal;
|
|
1313
|
+
if (proof.outcome === "exited" && receipt && terminal) {
|
|
1314
|
+
await recordConnectedCommandOutputConsumption(db, {
|
|
1315
|
+
...claim,
|
|
1316
|
+
receipt,
|
|
1317
|
+
exitCode: terminal.outcome.response.exitCode,
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1229
1320
|
if (settlement.events.length > 0) {
|
|
1230
1321
|
try {
|
|
1231
1322
|
await bus.publish(claim.workspaceId, claim.sessionId, settlement.events);
|
|
@@ -1250,6 +1341,168 @@ export async function reconcileConnectedMachineBackgroundCommands(
|
|
|
1250
1341
|
} while (Date.now() < deadline);
|
|
1251
1342
|
}
|
|
1252
1343
|
|
|
1344
|
+
export async function replayConnectedCommandOutput(
|
|
1345
|
+
client: Pick<OpStreamExecClient, "readExisting">,
|
|
1346
|
+
opId: string,
|
|
1347
|
+
terminalKnown: boolean,
|
|
1348
|
+
capture: Parameters<OpStreamExecClient["readExisting"]>[2],
|
|
1349
|
+
): Promise<Awaited<ReturnType<OpStreamExecClient["readExisting"]>>> {
|
|
1350
|
+
// A completed operation can need several bounded reads to replay its retained
|
|
1351
|
+
// frames. Keep the reader's integrity checkpoint until the exit is verified;
|
|
1352
|
+
// replacing it after each partial read would restart forever at frame one.
|
|
1353
|
+
let capturedThrough = 0n;
|
|
1354
|
+
while (true) {
|
|
1355
|
+
const before = capturedThrough;
|
|
1356
|
+
const replay = await client.readExisting(opId, terminalKnown ? 5_000 : 250, capture);
|
|
1357
|
+
if (!terminalKnown || replay.status === "completed" || replay.terminal) return replay;
|
|
1358
|
+
// Quiet jobs can retain many heartbeat frames (or an incomplete UTF-8
|
|
1359
|
+
// chunk). These are real replay progress even when capture receives no
|
|
1360
|
+
// stdout/stderr. Use the reader's verified, contiguous protocol frontier.
|
|
1361
|
+
capturedThrough = BigInt(replay.replaySequence);
|
|
1362
|
+
// This is a finite terminal drain, not a poller for a running command.
|
|
1363
|
+
// Failed persistence, transport/integrity errors, and no-progress reads
|
|
1364
|
+
// return control to normal reconciliation without licensing settlement.
|
|
1365
|
+
if (capturedThrough === before) {
|
|
1366
|
+
throw new Error("Connected command terminal output replay has not reached its exit frontier");
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Drain the durable terminal-output obligation independently of the launching
|
|
1372
|
+
* attempt or model completion notice. Existing terminal rows receive no inferred
|
|
1373
|
+
* receipt: they must replay and capture all retained bytes before any final ACK. */
|
|
1374
|
+
export async function reconcileConnectedCommandOutputReleases(
|
|
1375
|
+
db: ActivityServices["db"],
|
|
1376
|
+
settings: ActivityServices["settings"],
|
|
1377
|
+
observability: ActivityServices["observability"],
|
|
1378
|
+
bus: ActivityServices["bus"],
|
|
1379
|
+
controlRpcOverride?: ControlRpc,
|
|
1380
|
+
): Promise<void> {
|
|
1381
|
+
const dueBefore = new Date();
|
|
1382
|
+
const deadline = Date.now() + settings.sandboxLeaseReaperPeriodMs;
|
|
1383
|
+
const controlRpc =
|
|
1384
|
+
controlRpcOverride ?? new NatsControlRpc(async () => bus.getRequestConnection());
|
|
1385
|
+
do {
|
|
1386
|
+
let claims: Awaited<ReturnType<typeof claimConnectedCommandOutputReleases>>;
|
|
1387
|
+
try {
|
|
1388
|
+
claims = await claimConnectedCommandOutputReleases(db, {
|
|
1389
|
+
claimId: crypto.randomUUID(),
|
|
1390
|
+
dueBefore,
|
|
1391
|
+
limit: CONNECTED_COMMAND_RECONCILIATION_LIMIT,
|
|
1392
|
+
claimTtlMs: CONNECTED_COMMAND_RECONCILIATION_CLAIM_TTL_MS,
|
|
1393
|
+
});
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
recordConnectedCommandReconciliation(observability, "claim_failed");
|
|
1396
|
+
observability.warn("sandbox reaper: connected-command output claim failed", {
|
|
1397
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1398
|
+
});
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
await forEachWithConcurrency(claims, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (claim) => {
|
|
1402
|
+
try {
|
|
1403
|
+
if (!bus.getOpStreamConnection)
|
|
1404
|
+
throw new Error("Connected command output transport unavailable");
|
|
1405
|
+
const rpcSubject = subjectFor(
|
|
1406
|
+
claim.controlWorkspaceId,
|
|
1407
|
+
claim.enrollmentId,
|
|
1408
|
+
claim.connectionInstanceId,
|
|
1409
|
+
);
|
|
1410
|
+
const controlTimeoutMs = Math.min(
|
|
1411
|
+
settings.sandboxSelfhostedControlTimeoutMs,
|
|
1412
|
+
RETAINED_PROCESS_PROVIDER_PROBE_TIMEOUT_MS,
|
|
1413
|
+
);
|
|
1414
|
+
const client = new OpStreamExecClient({
|
|
1415
|
+
workspaceId: claim.controlWorkspaceId,
|
|
1416
|
+
agentId: claim.enrollmentId,
|
|
1417
|
+
connectionInstanceId: claim.connectionInstanceId,
|
|
1418
|
+
epoch: 0,
|
|
1419
|
+
controlRpc,
|
|
1420
|
+
rpcSubject,
|
|
1421
|
+
transport: new NatsOpStreamTransport(async () => bus.getOpStreamConnection?.() ?? null),
|
|
1422
|
+
controlTimeoutMs,
|
|
1423
|
+
retryClock: defaultSelfhostedRetryClock,
|
|
1424
|
+
journal: {
|
|
1425
|
+
attachGeneration: () => String(Date.now()),
|
|
1426
|
+
persistSettled: () => {
|
|
1427
|
+
throw new Error("Background output release requires its database receipt");
|
|
1428
|
+
},
|
|
1429
|
+
},
|
|
1430
|
+
});
|
|
1431
|
+
let receipt = claim.receipt;
|
|
1432
|
+
if (!receipt) {
|
|
1433
|
+
const status = await querySelfhostedOp({
|
|
1434
|
+
controlRpc,
|
|
1435
|
+
rpcSubject,
|
|
1436
|
+
opId: claim.opId,
|
|
1437
|
+
controlTimeoutMs,
|
|
1438
|
+
});
|
|
1439
|
+
if (status.state === OpState.OP_STATE_LOST) {
|
|
1440
|
+
if (
|
|
1441
|
+
!(await settleConnectedCommandOutputReleaseClaim(db, {
|
|
1442
|
+
claim,
|
|
1443
|
+
outcome: "unavailable",
|
|
1444
|
+
retryAfterMs: 0,
|
|
1445
|
+
}))
|
|
1446
|
+
)
|
|
1447
|
+
throw new Error("Connected command output loss no longer owns its exact claim");
|
|
1448
|
+
recordConnectedCommandReconciliation(observability, "output_unavailable");
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (status.state !== OpState.OP_STATE_COMPLETE)
|
|
1452
|
+
throw new Error("Terminal command output returned a live operation");
|
|
1453
|
+
const replay = await replayConnectedCommandOutput(
|
|
1454
|
+
client,
|
|
1455
|
+
claim.opId,
|
|
1456
|
+
true,
|
|
1457
|
+
async (frames) =>
|
|
1458
|
+
captureConnectedCommandOutput(db, claim, bus)(claim.commandId, frames),
|
|
1459
|
+
);
|
|
1460
|
+
const terminal = replay.status === "completed" ? replay : replay.terminal;
|
|
1461
|
+
receipt =
|
|
1462
|
+
replay.outputReceipt ??
|
|
1463
|
+
(replay.status === "running" ? replay.terminal?.outputReceipt : undefined) ??
|
|
1464
|
+
null;
|
|
1465
|
+
if (!terminal || !receipt)
|
|
1466
|
+
throw new Error("Connected command output replay has no complete custody receipt");
|
|
1467
|
+
await recordConnectedCommandOutputConsumption(db, {
|
|
1468
|
+
...claim,
|
|
1469
|
+
receipt,
|
|
1470
|
+
exitCode: terminal.outcome.response.exitCode,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
const outcome = await client.releaseCapturedOutput(claim.opId, receipt);
|
|
1474
|
+
if (
|
|
1475
|
+
!(await settleConnectedCommandOutputReleaseClaim(db, {
|
|
1476
|
+
claim,
|
|
1477
|
+
outcome,
|
|
1478
|
+
retryAfterMs: outcome === "published" ? settings.sandboxLeaseReaperPeriodMs : 0,
|
|
1479
|
+
}))
|
|
1480
|
+
)
|
|
1481
|
+
throw new Error("Connected command output release no longer owns its exact claim");
|
|
1482
|
+
recordConnectedCommandReconciliation(
|
|
1483
|
+
observability,
|
|
1484
|
+
outcome === "published" ? "output_ack_published" : "output_released",
|
|
1485
|
+
);
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
await settleConnectedCommandOutputReleaseClaim(db, {
|
|
1488
|
+
claim,
|
|
1489
|
+
outcome: "retry",
|
|
1490
|
+
retryAfterMs: Math.min(
|
|
1491
|
+
5 * 60_000,
|
|
1492
|
+
Math.max(settings.sandboxLeaseReaperPeriodMs, 30_000) *
|
|
1493
|
+
2 ** Math.min(4, claim.reconcileAttempts - 1),
|
|
1494
|
+
),
|
|
1495
|
+
}).catch(() => false);
|
|
1496
|
+
observability.warn("sandbox reaper: connected-command output release deferred", {
|
|
1497
|
+
commandId: claim.commandId,
|
|
1498
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
if (claims.length < CONNECTED_COMMAND_RECONCILIATION_LIMIT) break;
|
|
1503
|
+
} while (Date.now() < deadline);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1253
1506
|
export async function probeConnectedMachineBackgroundCommand(
|
|
1254
1507
|
claim: ConnectedMachineBackgroundCommandClaim,
|
|
1255
1508
|
controlRpc: ControlRpc,
|
|
@@ -1377,10 +1630,80 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1377
1630
|
return;
|
|
1378
1631
|
}
|
|
1379
1632
|
|
|
1633
|
+
const drainBudget: RetainedProcessDrainBudget = {
|
|
1634
|
+
until: Date.now() + RETAINED_PROCESS_OUTPUT_DRAIN_SWEEP_BUDGET_MS,
|
|
1635
|
+
};
|
|
1380
1636
|
for (const claim of claims) {
|
|
1381
1637
|
let process = claim.process;
|
|
1382
1638
|
const expected = retainedProcessSettlementIdentity(process);
|
|
1383
1639
|
let proof = retainedProcessReconciliationProof(process);
|
|
1640
|
+
const processScope = {
|
|
1641
|
+
accountId: process.accountId,
|
|
1642
|
+
workspaceId: process.workspaceId,
|
|
1643
|
+
sessionId: process.sessionId,
|
|
1644
|
+
processId: process.id,
|
|
1645
|
+
};
|
|
1646
|
+
// Recover a yield whose observation failed before normal receipt adoption.
|
|
1647
|
+
// Do this before provider I/O: an unavailable observer must not fence every
|
|
1648
|
+
// subsequent turn. Physical retention and unknown outcome stay unchanged.
|
|
1649
|
+
if (
|
|
1650
|
+
process.ownerActorKind === "turn" &&
|
|
1651
|
+
process.providerBackend === "modal" &&
|
|
1652
|
+
!proof &&
|
|
1653
|
+
!claim.ownerState.startsWith("background_")
|
|
1654
|
+
) {
|
|
1655
|
+
try {
|
|
1656
|
+
const command = await recoverManagedSessionBackgroundCommand(db, {
|
|
1657
|
+
...processScope,
|
|
1658
|
+
expected,
|
|
1659
|
+
reconciliationClaimId: claim.claimId,
|
|
1660
|
+
});
|
|
1661
|
+
if (command) {
|
|
1662
|
+
claim.ownerState = `background_${command.state}`;
|
|
1663
|
+
recordRetainedProcessReconciliation(observability, "background_recovered");
|
|
1664
|
+
}
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
observability.warn("sandbox reaper: retained-command background recovery failed", {
|
|
1667
|
+
processId: process.id,
|
|
1668
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const storedCommandPersistence = retainedProviderCommandPersistence(
|
|
1673
|
+
db,
|
|
1674
|
+
processScope,
|
|
1675
|
+
bus ? (events) => bus.publish(process.workspaceId, process.sessionId, events) : undefined,
|
|
1676
|
+
);
|
|
1677
|
+
const commandPersistence = {
|
|
1678
|
+
...storedCommandPersistence,
|
|
1679
|
+
requestCancellation: async (reason: "provider_deadline" | "explicit_stop") => {
|
|
1680
|
+
if (reason === "provider_deadline") {
|
|
1681
|
+
await requestRetainedProcessDeadlineCancellation(db, processScope);
|
|
1682
|
+
if (!(await storedCommandPersistence.cancellationRequested()))
|
|
1683
|
+
throw new Error(
|
|
1684
|
+
"Supervised deadline cancellation no longer owns its original rotating lease",
|
|
1685
|
+
);
|
|
1686
|
+
} else await storedCommandPersistence.requestCancellation(reason);
|
|
1687
|
+
},
|
|
1688
|
+
};
|
|
1689
|
+
if (proof && process.providerBackend === "modal") {
|
|
1690
|
+
try {
|
|
1691
|
+
const command = await commandPersistence.load();
|
|
1692
|
+
if (
|
|
1693
|
+
command?.kind === "modal-router-v1" &&
|
|
1694
|
+
command.supervision &&
|
|
1695
|
+
(!(await commandPersistence.loadSupervisionReceipt()) ||
|
|
1696
|
+
![command.streams.stdout, command.streams.stderr].every(
|
|
1697
|
+
(stream) => stream.eof && stream.exitCode === 0,
|
|
1698
|
+
))
|
|
1699
|
+
)
|
|
1700
|
+
proof = null;
|
|
1701
|
+
} catch {
|
|
1702
|
+
// An earlier terminal observation cannot make missing control/output
|
|
1703
|
+
// proof unreachable. Reprobe the original binding; DB remains the gate.
|
|
1704
|
+
proof = null;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1384
1707
|
if (!proof) {
|
|
1385
1708
|
let observation: RetainedProcessProbeResult | null = null;
|
|
1386
1709
|
|
|
@@ -1434,6 +1757,25 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1434
1757
|
}
|
|
1435
1758
|
}
|
|
1436
1759
|
|
|
1760
|
+
if (
|
|
1761
|
+
observation?.status === "deferred" &&
|
|
1762
|
+
process.providerBackend === "modal" &&
|
|
1763
|
+
!process.providerBindingKey &&
|
|
1764
|
+
!process.deadlineCancellationRequestedAt
|
|
1765
|
+
) {
|
|
1766
|
+
// An unbound historical command cannot be signalled safely. Still
|
|
1767
|
+
// record deadline intent when its lease matches: a failed observer
|
|
1768
|
+
// must not block capture until the provider expires.
|
|
1769
|
+
try {
|
|
1770
|
+
await requestRetainedProcessDeadlineCancellation(db, processScope);
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
observability.warn("sandbox reaper: legacy deadline cancellation intent failed", {
|
|
1773
|
+
processId: process.id,
|
|
1774
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1437
1779
|
if (observation === null) {
|
|
1438
1780
|
const lease = await readLease(db, process.workspaceId, process.sandboxGroupId).catch(
|
|
1439
1781
|
() => null,
|
|
@@ -1490,11 +1832,39 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1490
1832
|
}
|
|
1491
1833
|
} else {
|
|
1492
1834
|
try {
|
|
1493
|
-
|
|
1835
|
+
const command = await commandPersistence.load();
|
|
1836
|
+
const supervised = command?.kind === "modal-router-v1" && Boolean(command.supervision);
|
|
1837
|
+
const supervisionMetric = (outcome: string) =>
|
|
1838
|
+
observability.incrementCounter({
|
|
1839
|
+
name: "opengeni_command_supervision_total",
|
|
1840
|
+
help: "Native retained command supervision reconciliation outcomes.",
|
|
1841
|
+
labels: { outcome },
|
|
1842
|
+
});
|
|
1843
|
+
if (
|
|
1844
|
+
supervised &&
|
|
1845
|
+
(lease!.rotationReason === "provider_deadline" ||
|
|
1846
|
+
claim.ownerState === "background_stopping")
|
|
1847
|
+
) {
|
|
1848
|
+
await commandPersistence.requestCancellation(
|
|
1849
|
+
lease!.rotationReason === "provider_deadline"
|
|
1850
|
+
? "provider_deadline"
|
|
1851
|
+
: "explicit_stop",
|
|
1852
|
+
);
|
|
1853
|
+
supervisionMetric("cancellation_intent");
|
|
1854
|
+
}
|
|
1855
|
+
const legacyDeadlineStop =
|
|
1856
|
+
lease!.rotationReason === "provider_deadline" &&
|
|
1857
|
+
!supervised &&
|
|
1858
|
+
!process.deadlineCancellationRequestedAt;
|
|
1859
|
+
const probeOutcome = await probe(
|
|
1494
1860
|
settings,
|
|
1495
1861
|
lease!,
|
|
1496
1862
|
process,
|
|
1497
|
-
claim.ownerState === "background_stopping"
|
|
1863
|
+
(claim.ownerState === "background_stopping" &&
|
|
1864
|
+
!process.deadlineCancellationRequestedAt) ||
|
|
1865
|
+
legacyDeadlineStop
|
|
1866
|
+
? "cancel"
|
|
1867
|
+
: "observe",
|
|
1498
1868
|
async (result, chunkId, stream, streamFidelity) => {
|
|
1499
1869
|
if (
|
|
1500
1870
|
typeof result !== "string" ||
|
|
@@ -1516,13 +1886,35 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1516
1886
|
.publish(process.workspaceId, process.sessionId, events)
|
|
1517
1887
|
.catch(() => undefined);
|
|
1518
1888
|
},
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
}),
|
|
1889
|
+
commandPersistence,
|
|
1890
|
+
drainBudget,
|
|
1891
|
+
).then(
|
|
1892
|
+
(value) => ({ ok: true as const, value }),
|
|
1893
|
+
(error: unknown) => ({ ok: false as const, error }),
|
|
1525
1894
|
);
|
|
1895
|
+
// Legacy PTY input reserves Ctrl-C before the cancellation fence.
|
|
1896
|
+
// Non-PTY has no command signal in the Modal SDK: probe it without
|
|
1897
|
+
// injecting Ctrl-C as data, then fence and save after the grace.
|
|
1898
|
+
if (
|
|
1899
|
+
legacyDeadlineStop &&
|
|
1900
|
+
!(await requestRetainedProcessDeadlineCancellation(db, processScope))
|
|
1901
|
+
)
|
|
1902
|
+
throw new Error("Deadline cancellation no longer owns its rotating lease");
|
|
1903
|
+
if (!probeOutcome.ok) throw probeOutcome.error;
|
|
1904
|
+
observation = probeOutcome.value;
|
|
1905
|
+
if (supervised) {
|
|
1906
|
+
supervisionMetric(
|
|
1907
|
+
(await commandPersistence.loadSupervisionReceipt())
|
|
1908
|
+
? "proof_retained"
|
|
1909
|
+
: "proof_missing",
|
|
1910
|
+
);
|
|
1911
|
+
if (observation.status === "deferred")
|
|
1912
|
+
supervisionMetric(
|
|
1913
|
+
observation.reason === "provider_running"
|
|
1914
|
+
? "checkpoint_blocked"
|
|
1915
|
+
: "provider_failure",
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1526
1918
|
} catch (error) {
|
|
1527
1919
|
observability.warn("sandbox reaper: retained-process provider probe failed", {
|
|
1528
1920
|
processId: process.id,
|
|
@@ -1564,6 +1956,7 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1564
1956
|
expected,
|
|
1565
1957
|
claim.claimId,
|
|
1566
1958
|
observation.reason,
|
|
1959
|
+
observation.outputBacklog === true,
|
|
1567
1960
|
);
|
|
1568
1961
|
continue;
|
|
1569
1962
|
}
|
|
@@ -1581,10 +1974,7 @@ async function reconcileTerminalRetainedProcesses(
|
|
|
1581
1974
|
recordRetainedProcessReconciliation(observability, `proof_${proof.outcome}`);
|
|
1582
1975
|
} catch (error) {
|
|
1583
1976
|
recordRetainedProcessReconciliation(observability, "proof_checkpoint_failed");
|
|
1584
|
-
observability
|
|
1585
|
-
processId: process.id,
|
|
1586
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1587
|
-
});
|
|
1977
|
+
warnRetainedProcessProofFailure(observability, error, process);
|
|
1588
1978
|
continue;
|
|
1589
1979
|
}
|
|
1590
1980
|
}
|
|
@@ -1647,9 +2037,17 @@ async function deferRetainedProcessClaim(
|
|
|
1647
2037
|
expected: ReturnType<typeof retainedProcessSettlementIdentity>,
|
|
1648
2038
|
claimId: string,
|
|
1649
2039
|
outcome: RetainedProcessDeferralOutcome,
|
|
2040
|
+
outputBacklog = false,
|
|
1650
2041
|
): Promise<void> {
|
|
1651
|
-
const deferral = retainedProcessReconciliationDeferral(settings, process, outcome);
|
|
2042
|
+
const deferral = retainedProcessReconciliationDeferral(settings, process, outcome, outputBacklog);
|
|
1652
2043
|
try {
|
|
2044
|
+
// A healthy long-lived command can already be on a five-minute backoff
|
|
2045
|
+
// when rotation becomes due. Wake it at the lead boundary, then at the
|
|
2046
|
+
// reaper cadence, so observation does not consume the capture window.
|
|
2047
|
+
const lease =
|
|
2048
|
+
process.providerBackend === "modal" && process.routeTargetId === null
|
|
2049
|
+
? await readLease(db, process.workspaceId, process.sandboxGroupId)
|
|
2050
|
+
: null;
|
|
1653
2051
|
await deferRetainedProcessReconciliation(db, {
|
|
1654
2052
|
accountId: process.accountId,
|
|
1655
2053
|
workspaceId: process.workspaceId,
|
|
@@ -1658,7 +2056,7 @@ async function deferRetainedProcessClaim(
|
|
|
1658
2056
|
expected,
|
|
1659
2057
|
claimId,
|
|
1660
2058
|
outcome: deferral.durableOutcome,
|
|
1661
|
-
retryAfterMs: deferral.retryAfterMs,
|
|
2059
|
+
retryAfterMs: retainedProcessDeadlineRetryMs(process, lease, settings, deferral.retryAfterMs),
|
|
1662
2060
|
});
|
|
1663
2061
|
recordRetainedProcessReconciliation(observability, deferral.metricOutcome);
|
|
1664
2062
|
} catch (error) {
|
|
@@ -1678,6 +2076,7 @@ export function retainedProcessReconciliationDeferral(
|
|
|
1678
2076
|
settings: Pick<ActivityServices["settings"], "sandboxLeaseReaperPeriodMs">,
|
|
1679
2077
|
process: Pick<SandboxRetainedProcess, "reconcileAttempts">,
|
|
1680
2078
|
outcome: RetainedProcessDeferralOutcome,
|
|
2079
|
+
outputBacklog = false,
|
|
1681
2080
|
): {
|
|
1682
2081
|
durableOutcome: string;
|
|
1683
2082
|
metricOutcome: RetainedProcessReconciliationOutcome;
|
|
@@ -1702,11 +2101,14 @@ export function retainedProcessReconciliationDeferral(
|
|
|
1702
2101
|
quarantined: true,
|
|
1703
2102
|
};
|
|
1704
2103
|
}
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
2104
|
+
const baseRetryMs = Math.max(settings.sandboxLeaseReaperPeriodMs, 30_000);
|
|
2105
|
+
// A backlog still being drained is progress, not an unhealthy provider:
|
|
2106
|
+
// come back at the reaper cadence, without backoff.
|
|
2107
|
+
const exponent =
|
|
2108
|
+
outcome === "provider_running" && outputBacklog
|
|
2109
|
+
? 0
|
|
2110
|
+
: Math.min(4, Math.max(0, process.reconcileAttempts - 1));
|
|
2111
|
+
const retryAfterMs = Math.min(5 * 60_000, baseRetryMs * 2 ** exponent);
|
|
1710
2112
|
return {
|
|
1711
2113
|
durableOutcome: outcome,
|
|
1712
2114
|
metricOutcome: outcome,
|
|
@@ -1733,6 +2135,77 @@ type RetainedProcessProbeSession = ProviderCommandSession & {
|
|
|
1733
2135
|
|
|
1734
2136
|
const RETAINED_PROCESS_PROBE_TIMEOUT = Symbol("retained-process-provider-probe-timeout");
|
|
1735
2137
|
|
|
2138
|
+
/** A command's exit is reported only after both output streams reach EOF, so a
|
|
2139
|
+
* finished command with a large backlog looks running until it is read. While a
|
|
2140
|
+
* backlog remains, keep reading within the per-claim and per-sweep budgets, so
|
|
2141
|
+
* it settles in this claim instead of one page per backoff step. Every page,
|
|
2142
|
+
* including `first`, is captured here. */
|
|
2143
|
+
export async function drainRetainedCommandBacklog(
|
|
2144
|
+
session: RetainedProcessProbeSession,
|
|
2145
|
+
providerSessionId: number,
|
|
2146
|
+
first: unknown,
|
|
2147
|
+
capturePage: (value: unknown) => Promise<void>,
|
|
2148
|
+
budget?: RetainedProcessDrainBudget,
|
|
2149
|
+
): Promise<RetainedProcessProbeResult> {
|
|
2150
|
+
// Measure before capture: capturing a byte-offset page consumes its receipt.
|
|
2151
|
+
let backlog = retainedBacklogRemains(session, first);
|
|
2152
|
+
await capturePage(first);
|
|
2153
|
+
let observation = classifyRetainedProcessPollResult(first, providerSessionId, session);
|
|
2154
|
+
if (typeof session.writeStdin !== "function") return observation;
|
|
2155
|
+
const until = Math.min(
|
|
2156
|
+
Date.now() + RETAINED_PROCESS_OUTPUT_DRAIN_BUDGET_MS,
|
|
2157
|
+
budget?.until ?? Number.POSITIVE_INFINITY,
|
|
2158
|
+
);
|
|
2159
|
+
for (
|
|
2160
|
+
let reads = 0;
|
|
2161
|
+
backlog &&
|
|
2162
|
+
observation.status === "deferred" &&
|
|
2163
|
+
observation.reason === "provider_running" &&
|
|
2164
|
+
reads < RETAINED_PROCESS_OUTPUT_DRAIN_MAX_READS &&
|
|
2165
|
+
Date.now() < until;
|
|
2166
|
+
reads++
|
|
2167
|
+
) {
|
|
2168
|
+
let next: unknown;
|
|
2169
|
+
try {
|
|
2170
|
+
next = await withRetainedProcessProbeTimeout(
|
|
2171
|
+
session.writeStdin({
|
|
2172
|
+
sessionId: providerSessionId,
|
|
2173
|
+
chars: "",
|
|
2174
|
+
yieldTimeMs: 1_000,
|
|
2175
|
+
maxOutputTokens: 2_000,
|
|
2176
|
+
}),
|
|
2177
|
+
);
|
|
2178
|
+
} catch {
|
|
2179
|
+
// The observation already made stays authoritative; the next sweep
|
|
2180
|
+
// classifies any provider failure through the ordinary path.
|
|
2181
|
+
break;
|
|
2182
|
+
}
|
|
2183
|
+
backlog = retainedBacklogRemains(session, next);
|
|
2184
|
+
await capturePage(next);
|
|
2185
|
+
observation = classifyRetainedProcessPollResult(next, providerSessionId, session);
|
|
2186
|
+
}
|
|
2187
|
+
return backlog && observation.status === "deferred" && observation.reason === "provider_running"
|
|
2188
|
+
? { ...observation, outputBacklog: true }
|
|
2189
|
+
: observation;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/** Whether a byte-offset page leaves a backlog worth reading now: the provider
|
|
2193
|
+
* reports the process exited but its output is not yet read to EOF, or a live
|
|
2194
|
+
* process produced a large page. Must run before the page is captured, which
|
|
2195
|
+
* consumes its receipt. Supervised commands are left to their proof path. */
|
|
2196
|
+
function retainedBacklogRemains(session: RetainedProcessProbeSession, result: unknown): boolean {
|
|
2197
|
+
const page = session.getProviderCommandOutput?.(result);
|
|
2198
|
+
if (!page?.expected || page.command.kind !== "modal-router-v1") return false;
|
|
2199
|
+
if (page.command.supervision || page.exitCode !== null) return false;
|
|
2200
|
+
if (page.providerExited) return true;
|
|
2201
|
+
const { command, expected } = page;
|
|
2202
|
+
return (["stdout", "stderr"] as const).some(
|
|
2203
|
+
(stream) =>
|
|
2204
|
+
command.streams[stream].byteOffset - expected.streams[stream].byteOffset >=
|
|
2205
|
+
RETAINED_PROCESS_OUTPUT_BACKLOG_BYTES,
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1736
2209
|
async function withRetainedProcessProbeTimeout<T>(promise: Promise<T>): Promise<T> {
|
|
1737
2210
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1738
2211
|
try {
|
|
@@ -1781,6 +2254,7 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1781
2254
|
streamFidelity?: "separate" | "merged",
|
|
1782
2255
|
) => Promise<void>,
|
|
1783
2256
|
providerPersistence?: ProviderCommandPersistence,
|
|
2257
|
+
drainBudget?: RetainedProcessDrainBudget,
|
|
1784
2258
|
): Promise<RetainedProcessProbeResult> {
|
|
1785
2259
|
if (
|
|
1786
2260
|
lease.id !== process.leaseId ||
|
|
@@ -1788,7 +2262,8 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1788
2262
|
lease.leaseEpoch !== process.leaseEpoch ||
|
|
1789
2263
|
lease.backend !== process.providerBackend ||
|
|
1790
2264
|
lease.instanceId !== process.providerInstanceId ||
|
|
1791
|
-
lease.resumeBackendId
|
|
2265
|
+
!lease.resumeBackendId ||
|
|
2266
|
+
sandboxBackendForSdkBackendId(lease.resumeBackendId) !== process.providerBackend ||
|
|
1792
2267
|
process.routeTargetId !== null
|
|
1793
2268
|
) {
|
|
1794
2269
|
return { status: "deferred", reason: "identity_mismatch" };
|
|
@@ -1800,10 +2275,20 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1800
2275
|
if (pending) {
|
|
1801
2276
|
if (!captureOutput) throw new Error("Pending command output requires its persistence callback");
|
|
1802
2277
|
await captureRetainedProbeOutput(process.id, pending.result, captureOutput);
|
|
1803
|
-
|
|
2278
|
+
const pendingObservation = classifyRetainedProcessPollResult(
|
|
2279
|
+
pending.result,
|
|
2280
|
+
process.providerSessionId,
|
|
2281
|
+
);
|
|
2282
|
+
// Capture of a previous page must not consume the only deadline stop
|
|
2283
|
+
// attempt. Terminal proof needs no further provider call.
|
|
2284
|
+
if (mode !== "cancel" || pendingObservation.status === "proved") return pendingObservation;
|
|
1804
2285
|
}
|
|
1805
2286
|
const envelopeBackend = (lease.resumeState as { backendId?: unknown }).backendId;
|
|
1806
|
-
if (
|
|
2287
|
+
if (
|
|
2288
|
+
envelopeBackend !== undefined &&
|
|
2289
|
+
(typeof envelopeBackend !== "string" ||
|
|
2290
|
+
sandboxBackendForSdkBackendId(envelopeBackend) !== process.providerBackend)
|
|
2291
|
+
) {
|
|
1807
2292
|
return { status: "deferred", reason: "identity_mismatch" };
|
|
1808
2293
|
}
|
|
1809
2294
|
if (sandboxProviderInstanceIdFromEnvelope(lease.resumeState) !== process.providerInstanceId) {
|
|
@@ -1837,7 +2322,11 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1837
2322
|
} catch {
|
|
1838
2323
|
return { status: "deferred", reason: "provider_error" };
|
|
1839
2324
|
}
|
|
1840
|
-
if (
|
|
2325
|
+
if (
|
|
2326
|
+
!client ||
|
|
2327
|
+
sandboxBackendForSdkBackendId(client.backendId) !== process.providerBackend ||
|
|
2328
|
+
!client.resume
|
|
2329
|
+
) {
|
|
1841
2330
|
return { status: "deferred", reason: "backend_unsupported" };
|
|
1842
2331
|
}
|
|
1843
2332
|
const envelopeSessionState =
|
|
@@ -1889,6 +2378,7 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1889
2378
|
if (typeof session.writeStdin !== "function") {
|
|
1890
2379
|
return { status: "deferred", reason: "backend_unsupported" };
|
|
1891
2380
|
}
|
|
2381
|
+
let legacyPty = false;
|
|
1892
2382
|
if (process.providerBackend === "modal") {
|
|
1893
2383
|
let liveBinding: Awaited<ReturnType<typeof resolveModalCheckpointProviderBindingForSession>>;
|
|
1894
2384
|
try {
|
|
@@ -1920,6 +2410,7 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1920
2410
|
if (!command || !providerPersistence || !session.bindProviderCommand)
|
|
1921
2411
|
return { status: "deferred", reason: "process_observation_unavailable" };
|
|
1922
2412
|
session.bindProviderCommand(process.providerSessionId, command, providerPersistence);
|
|
2413
|
+
legacyPty = command.pty === true;
|
|
1923
2414
|
}
|
|
1924
2415
|
const capturePage = async (value: unknown): Promise<void> => {
|
|
1925
2416
|
if (!captureOutput) {
|
|
@@ -1928,6 +2419,11 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1928
2419
|
return;
|
|
1929
2420
|
}
|
|
1930
2421
|
const page = session.getProviderCommandOutput?.(value);
|
|
2422
|
+
if (page?.command.kind === "modal-router-v1" && typeof value === "string") {
|
|
2423
|
+
if (!session.captureCommandOutput || !(await session.captureCommandOutput(value)))
|
|
2424
|
+
throw new Error("Byte-offset output requires atomic capture before settlement");
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
1931
2427
|
if (page) {
|
|
1932
2428
|
for (const chunk of page.chunks)
|
|
1933
2429
|
if (chunk.text)
|
|
@@ -1940,10 +2436,24 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1940
2436
|
|
|
1941
2437
|
let result: unknown;
|
|
1942
2438
|
try {
|
|
2439
|
+
const supervisedCancelled =
|
|
2440
|
+
mode === "cancel" || lease.rotationReason === "provider_deadline"
|
|
2441
|
+
? ((await session.cancelSupervisedCommand?.(
|
|
2442
|
+
process.providerSessionId,
|
|
2443
|
+
lease.rotationReason === "provider_deadline" ? "provider_deadline" : "explicit_stop",
|
|
2444
|
+
)) ?? false)
|
|
2445
|
+
: false;
|
|
1943
2446
|
result = await withRetainedProcessProbeTimeout(
|
|
1944
2447
|
session.writeStdin({
|
|
1945
2448
|
sessionId: process.providerSessionId,
|
|
1946
|
-
|
|
2449
|
+
// Ctrl-C is a signal only through a PTY. For a non-PTY command it is
|
|
2450
|
+
// ordinary stdin data and can corrupt the command's input.
|
|
2451
|
+
chars:
|
|
2452
|
+
mode === "cancel" &&
|
|
2453
|
+
!supervisedCancelled &&
|
|
2454
|
+
(process.providerBackend !== "modal" || legacyPty)
|
|
2455
|
+
? "\u0003"
|
|
2456
|
+
: "",
|
|
1947
2457
|
yieldTimeMs: 1_000,
|
|
1948
2458
|
maxOutputTokens: 2_000,
|
|
1949
2459
|
}),
|
|
@@ -1967,12 +2477,25 @@ export async function probeRetainedProcessAtProvider(
|
|
|
1967
2477
|
}
|
|
1968
2478
|
return { status: "deferred", reason: "provider_error" };
|
|
1969
2479
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
2480
|
+
// Cancellation keeps draining with empty reads too: a stopped command's exit
|
|
2481
|
+
// is also reported only after its unread output reaches EOF.
|
|
2482
|
+
const observation = await drainRetainedCommandBacklog(
|
|
2483
|
+
session,
|
|
2484
|
+
process.providerSessionId,
|
|
2485
|
+
result,
|
|
2486
|
+
capturePage,
|
|
2487
|
+
drainBudget,
|
|
2488
|
+
);
|
|
1972
2489
|
if (
|
|
1973
2490
|
observation.status === "deferred" &&
|
|
1974
2491
|
observation.reason === "provider_running" &&
|
|
1975
|
-
lease.rotationRequestedAt !== null
|
|
2492
|
+
lease.rotationRequestedAt !== null &&
|
|
2493
|
+
mode !== "cancel" &&
|
|
2494
|
+
!process.cancellationRequestedAt &&
|
|
2495
|
+
legacyPty &&
|
|
2496
|
+
!(await providerPersistence
|
|
2497
|
+
?.load()
|
|
2498
|
+
.then((command) => command?.kind === "modal-router-v1" && command.supervision))
|
|
1976
2499
|
) {
|
|
1977
2500
|
// A background PTY cannot outlive the finite provider box. Interrupt only
|
|
1978
2501
|
// this exact durable provider session after rotation admission is fenced;
|
|
@@ -2075,19 +2598,25 @@ async function accrueWarmTick(
|
|
|
2075
2598
|
}
|
|
2076
2599
|
await forEachWithConcurrency(leases, SANDBOX_MAINTENANCE_ITEM_CONCURRENCY, async (lease) => {
|
|
2077
2600
|
try {
|
|
2078
|
-
const rate =
|
|
2601
|
+
const rate =
|
|
2602
|
+
settings.sandboxWarmBillingMode === "usage_only"
|
|
2603
|
+
? 0
|
|
2604
|
+
: sandboxWarmRateMicrosPerSecond(settings, lease.backend);
|
|
2079
2605
|
const result = await accrueWarmSeconds(db, {
|
|
2080
2606
|
accountId: lease.accountId,
|
|
2081
2607
|
workspaceId: lease.workspaceId,
|
|
2082
2608
|
sandboxGroupId: lease.sandboxGroupId,
|
|
2083
2609
|
expectedEpoch: lease.leaseEpoch,
|
|
2084
2610
|
warmRateMicrosPerSecond: rate,
|
|
2611
|
+
billingMode: settings.sandboxWarmBillingMode,
|
|
2085
2612
|
subjectId: lease.sandboxGroupId,
|
|
2086
2613
|
});
|
|
2087
2614
|
if (result.accrued) {
|
|
2088
2615
|
accrued += 1;
|
|
2089
2616
|
}
|
|
2090
|
-
|
|
2617
|
+
if (result.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
|
|
2618
|
+
recordCreditMicros(observability, "usage", result.costMicros);
|
|
2619
|
+
}
|
|
2091
2620
|
} catch (error) {
|
|
2092
2621
|
observability.warn("sandbox reaper: warm-seconds accrual failed for lease", {
|
|
2093
2622
|
workspaceId: lease.workspaceId,
|
|
@@ -2129,6 +2658,17 @@ async function refreshQueueLeaseAndCreditGauges(
|
|
|
2129
2658
|
);
|
|
2130
2659
|
},
|
|
2131
2660
|
),
|
|
2661
|
+
refreshSandboxInventoryGauge(
|
|
2662
|
+
observability,
|
|
2663
|
+
"recovery_observations",
|
|
2664
|
+
"recovery-observations",
|
|
2665
|
+
async () => {
|
|
2666
|
+
recordSandboxRecoveryObservationGauges(
|
|
2667
|
+
observability,
|
|
2668
|
+
await readRecentSandboxRecoveryObservations(db),
|
|
2669
|
+
);
|
|
2670
|
+
},
|
|
2671
|
+
),
|
|
2132
2672
|
refreshSandboxInventoryGauge(
|
|
2133
2673
|
observability,
|
|
2134
2674
|
"rotation_backlog",
|
|
@@ -2181,6 +2721,51 @@ async function refreshQueueLeaseAndCreditGauges(
|
|
|
2181
2721
|
});
|
|
2182
2722
|
}
|
|
2183
2723
|
})(),
|
|
2724
|
+
(async () => {
|
|
2725
|
+
try {
|
|
2726
|
+
recordActiveUserGauges(observability, await countActiveUsers(db));
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
observability.warn("sandbox reaper: active-user gauge refresh failed", {
|
|
2729
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
})(),
|
|
2733
|
+
(async () => {
|
|
2734
|
+
try {
|
|
2735
|
+
recordCreditGrantGauges(observability, await readCreditGrantTotals(db));
|
|
2736
|
+
} catch (error) {
|
|
2737
|
+
observability.warn("sandbox reaper: credit-grant gauge refresh failed", {
|
|
2738
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
})(),
|
|
2742
|
+
(async () => {
|
|
2743
|
+
recordVerifiedSignupTrialDeploymentFlagGauge(
|
|
2744
|
+
observability,
|
|
2745
|
+
settings.verifiedSignupTrialCreditsEnabled,
|
|
2746
|
+
);
|
|
2747
|
+
try {
|
|
2748
|
+
const trialSwitch = await readVerifiedSignupTrialSwitch(db);
|
|
2749
|
+
recordVerifiedSignupTrialSwitchGauge(observability, trialSwitch?.grantsEnabled === true);
|
|
2750
|
+
} catch (error) {
|
|
2751
|
+
observability.warn("sandbox reaper: trial-credit switch gauge refresh failed", {
|
|
2752
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
})(),
|
|
2756
|
+
(async () => {
|
|
2757
|
+
try {
|
|
2758
|
+
const signupsSwitch = await readManagedAuthNewSignupsSwitch(db);
|
|
2759
|
+
recordManagedAuthNewSignupsSwitchGauge(
|
|
2760
|
+
observability,
|
|
2761
|
+
signupsSwitch?.signupsEnabled !== false,
|
|
2762
|
+
);
|
|
2763
|
+
} catch (error) {
|
|
2764
|
+
observability.warn("sandbox reaper: new-signups switch gauge refresh failed", {
|
|
2765
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
})(),
|
|
2184
2769
|
]);
|
|
2185
2770
|
}
|
|
2186
2771
|
|
|
@@ -2203,8 +2788,8 @@ async function refreshSandboxInventoryGauge(
|
|
|
2203
2788
|
|
|
2204
2789
|
/**
|
|
2205
2790
|
* The per-workspace warm-cap + force-drain pass (P2.1). For each workspace with a
|
|
2206
|
-
* live warm box, under the usage lock: if it is
|
|
2207
|
-
*
|
|
2791
|
+
* live warm box, under the usage lock: if it is over its optional warm cap,
|
|
2792
|
+
* accelerate only holderless boxes to draining
|
|
2208
2793
|
* (guarded turn_holders=0 — a paying turn is never killed). Returns the count of
|
|
2209
2794
|
* viewer-only boxes force-drained. Per-workspace best-effort.
|
|
2210
2795
|
*/
|
|
@@ -2214,8 +2799,7 @@ async function forceDrainOverLimitWorkspaces(
|
|
|
2214
2799
|
workspaceIds: Set<string>,
|
|
2215
2800
|
observability: ActivityServices["observability"],
|
|
2216
2801
|
): Promise<number> {
|
|
2217
|
-
const enforceBalance =
|
|
2218
|
-
settings.billingMode === "stripe" || settings.usageLimitsMode === "managed";
|
|
2802
|
+
const enforceBalance = false;
|
|
2219
2803
|
const cap = settings.sandboxMaxWarmSecondsPerWorkspace;
|
|
2220
2804
|
let forceDrained = 0;
|
|
2221
2805
|
await forEachWithConcurrency(
|
|
@@ -2223,13 +2807,8 @@ async function forceDrainOverLimitWorkspaces(
|
|
|
2223
2807
|
SANDBOX_MAINTENANCE_ITEM_CONCURRENCY,
|
|
2224
2808
|
async (workspaceId) => {
|
|
2225
2809
|
try {
|
|
2226
|
-
const { accountId } = await rlsContextForWorkspace(db, workspaceId);
|
|
2227
|
-
const balance = enforceBalance
|
|
2228
|
-
? await getBillingBalance(db, accountId)
|
|
2229
|
-
: ({ balanceMicros: 1 } as { balanceMicros: number });
|
|
2230
2810
|
const result = await forceDrainOverLimitViewerOnlyBoxes(db, {
|
|
2231
2811
|
workspaceId,
|
|
2232
|
-
balanceMicros: balance.balanceMicros,
|
|
2233
2812
|
enforceBalance,
|
|
2234
2813
|
maxWarmSecondsPerWorkspace: cap,
|
|
2235
2814
|
idleGraceMs: settings.sandboxIdleGraceMs,
|
|
@@ -2253,10 +2832,50 @@ async function forceDrainOverLimitWorkspaces(
|
|
|
2253
2832
|
return forceDrained;
|
|
2254
2833
|
}
|
|
2255
2834
|
|
|
2835
|
+
export async function reconcileModalProviderCreates(
|
|
2836
|
+
db: ActivityServices["db"],
|
|
2837
|
+
settings: ActivityServices["settings"],
|
|
2838
|
+
observability: ActivityServices["observability"],
|
|
2839
|
+
discover: typeof findModalProviderCreateReceipt = findModalProviderCreateReceipt,
|
|
2840
|
+
): Promise<void> {
|
|
2841
|
+
if (settings.sandboxBackend !== "modal" && !settings.modalTokenId && !settings.modalTokenSecret)
|
|
2842
|
+
return;
|
|
2843
|
+
const candidates = await listPendingModalProviderCreates(db);
|
|
2844
|
+
await forEachWithConcurrency(candidates, 2, async (candidate) => {
|
|
2845
|
+
try {
|
|
2846
|
+
const { accountId } = await rlsContextForWorkspace(db, candidate.workspaceId);
|
|
2847
|
+
const identity = { ...candidate, accountId };
|
|
2848
|
+
const attempt = await claimModalProviderCreateRecovery(db, identity);
|
|
2849
|
+
if (!attempt) return;
|
|
2850
|
+
const instanceId = await discover(settings, attempt);
|
|
2851
|
+
if (!instanceId) return; // Absence never settles an unknown dispatch.
|
|
2852
|
+
if (await recordRecoveredModalProviderCreate(db, { ...identity, attempt, instanceId })) {
|
|
2853
|
+
observability.info("sandbox reaper: recovered Modal create receipt", {
|
|
2854
|
+
...candidate,
|
|
2855
|
+
operationId: attempt.operationId,
|
|
2856
|
+
instanceId,
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
observability.warn("sandbox reaper: Modal create remains fenced pending discovery", {
|
|
2861
|
+
...candidate,
|
|
2862
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2256
2868
|
export function modalOrphanTerminationStillEligible(
|
|
2257
2869
|
latest: Awaited<ReturnType<typeof listLiveModalSandboxLeaseAttributions>>,
|
|
2258
2870
|
candidate: ModalOrphanSweepTermination,
|
|
2259
2871
|
): boolean {
|
|
2872
|
+
// The provider may have accepted a create whose reply has not arrived.
|
|
2873
|
+
// Before attribution, neither its ID nor its separately-applied tags are
|
|
2874
|
+
// available here. Postpone orphan deletion while any such owner exists;
|
|
2875
|
+
// age/absence cannot distinguish its provider from an abandoned one.
|
|
2876
|
+
if (latest.some((lease) => lease.liveness === "warming" && lease.instanceId === null)) {
|
|
2877
|
+
return false;
|
|
2878
|
+
}
|
|
2260
2879
|
if (latest.some((lease) => lease.instanceId === candidate.sandboxId)) {
|
|
2261
2880
|
return false;
|
|
2262
2881
|
}
|
|
@@ -2444,6 +3063,7 @@ async function terminateDrainableBox(
|
|
|
2444
3063
|
probeDrainableProvider: DrainableProviderProbeFn,
|
|
2445
3064
|
attempt: SandboxDrainCaptureAttempt,
|
|
2446
3065
|
objectStorage: ObjectStorage | null,
|
|
3066
|
+
bus: ActivityServices["bus"] | null = null,
|
|
2447
3067
|
): Promise<boolean> {
|
|
2448
3068
|
// Resolve the account for the RLS-scoped confirmDrainCold (the global sweep
|
|
2449
3069
|
// returns no account_id; the workspace->account map is the bootstrap read).
|
|
@@ -2799,6 +3419,38 @@ async function terminateDrainableBox(
|
|
|
2799
3419
|
// lease draining for a later sweep (NEVER terminate a box whose files we
|
|
2800
3420
|
// could not capture). A persist CAS miss means the box was re-armed and left
|
|
2801
3421
|
// running, so the cold commit is skipped.
|
|
3422
|
+
// A published capture retry skips persistence. Revalidate legacy containment
|
|
3423
|
+
// even on that path: stale enrollment is not native-supervisor exit proof.
|
|
3424
|
+
// Typed provider absence is independently fenced by the canonical loss commit.
|
|
3425
|
+
if (
|
|
3426
|
+
!providerMissingBeforeCapture &&
|
|
3427
|
+
backend === "modal" &&
|
|
3428
|
+
lease.instanceId &&
|
|
3429
|
+
!(await readWorkspaceArchiveCapturePreflight(db, {
|
|
3430
|
+
accountId,
|
|
3431
|
+
workspaceId: row.workspaceId,
|
|
3432
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3433
|
+
expectedEpoch: row.leaseEpoch,
|
|
3434
|
+
expectedInstanceId: lease.instanceId,
|
|
3435
|
+
liveness: "draining",
|
|
3436
|
+
}))
|
|
3437
|
+
) {
|
|
3438
|
+
return false;
|
|
3439
|
+
}
|
|
3440
|
+
// Stamp the charge horizon after verified capture and immediately before
|
|
3441
|
+
// provider stop. Capture can fail and release its claim, allowing re-arm.
|
|
3442
|
+
// A retry after stop success cannot charge post-termination wall time.
|
|
3443
|
+
let stopCutoffMarked = false;
|
|
3444
|
+
const beforeProviderStop = async (): Promise<void> => {
|
|
3445
|
+
const cutoff = await markWarmBillingStopCutoff(db, {
|
|
3446
|
+
accountId,
|
|
3447
|
+
workspaceId: row.workspaceId,
|
|
3448
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3449
|
+
expectedEpoch: row.leaseEpoch,
|
|
3450
|
+
});
|
|
3451
|
+
if (!cutoff) throw new Error("sandbox stop cutoff was fenced before provider termination");
|
|
3452
|
+
stopCutoffMarked = true;
|
|
3453
|
+
};
|
|
2802
3454
|
const termination: ProviderTerminationOutcome | boolean = providerMissingBeforeCapture
|
|
2803
3455
|
? { terminated: true, providerMissingBeforeCapture: true }
|
|
2804
3456
|
: await terminateBox(
|
|
@@ -2821,12 +3473,50 @@ async function terminateDrainableBox(
|
|
|
2821
3473
|
expectedInstanceId: lease.instanceId,
|
|
2822
3474
|
});
|
|
2823
3475
|
},
|
|
3476
|
+
row.workspaceId,
|
|
3477
|
+
beforeProviderStop,
|
|
2824
3478
|
);
|
|
2825
3479
|
const terminated = typeof termination === "boolean" ? termination : termination.terminated;
|
|
2826
3480
|
if (!terminated) {
|
|
2827
3481
|
return false;
|
|
2828
3482
|
}
|
|
2829
3483
|
|
|
3484
|
+
// Self-hosted/none and an unassigned lease have no provider stop callback;
|
|
3485
|
+
// finish their usage at logical detach without touching customer compute.
|
|
3486
|
+
// A managed instance that was stopped without its callback must fail closed.
|
|
3487
|
+
const providerMissingDuringStop =
|
|
3488
|
+
providerMissingBeforeCapture ||
|
|
3489
|
+
(typeof termination !== "boolean" && termination.providerMissingBeforeCapture);
|
|
3490
|
+
if (!stopCutoffMarked && !providerMissingDuringStop) {
|
|
3491
|
+
if (managedProvider && lease.instanceId) {
|
|
3492
|
+
throw new Error("managed sandbox stopped without a warm usage cutoff");
|
|
3493
|
+
}
|
|
3494
|
+
await beforeProviderStop();
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
// Stop was confirmed but the lease is not cold yet. This final epoch-fenced
|
|
3498
|
+
// tick settles the interval since the last heartbeat/reaper sweep, including
|
|
3499
|
+
// a box drained before its first periodic tick. If settlement fails, keep
|
|
3500
|
+
// the draining receipt for retry instead of silently losing the charge.
|
|
3501
|
+
const finalRate =
|
|
3502
|
+
settings.sandboxWarmBillingMode === "usage_only"
|
|
3503
|
+
? 0
|
|
3504
|
+
: sandboxWarmRateMicrosPerSecond(settings, backend);
|
|
3505
|
+
if (!providerMissingDuringStop) {
|
|
3506
|
+
const finalTick = await accrueWarmSeconds(db, {
|
|
3507
|
+
accountId,
|
|
3508
|
+
workspaceId: row.workspaceId,
|
|
3509
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3510
|
+
expectedEpoch: row.leaseEpoch,
|
|
3511
|
+
warmRateMicrosPerSecond: finalRate,
|
|
3512
|
+
billingMode: settings.sandboxWarmBillingMode,
|
|
3513
|
+
finalDrain: true,
|
|
3514
|
+
});
|
|
3515
|
+
if (finalTick.costMicros > 0 && settings.sandboxWarmBillingMode === "credits") {
|
|
3516
|
+
recordCreditMicros(observability, "usage", finalTick.costMicros);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
|
|
2830
3520
|
const providerMissing =
|
|
2831
3521
|
providerMissingBeforeCapture ||
|
|
2832
3522
|
(typeof termination === "boolean" ? false : termination.providerMissingBeforeCapture);
|
|
@@ -2834,15 +3524,36 @@ async function terminateDrainableBox(
|
|
|
2834
3524
|
// with draining->cold. Until this succeeds, arrivals remain fenced by that
|
|
2835
3525
|
// exact claim; a timestamp or a failed provider call can never reopen a box
|
|
2836
3526
|
// while termination may still be in flight.
|
|
2837
|
-
const { wentCold } = await confirmDrainCold(db, {
|
|
3527
|
+
const { wentCold, backgroundCommandEvents } = await confirmDrainCold(db, {
|
|
2838
3528
|
accountId,
|
|
2839
3529
|
workspaceId: row.workspaceId,
|
|
2840
3530
|
sandboxGroupId: row.sandboxGroupId,
|
|
2841
3531
|
expectedEpoch: row.leaseEpoch,
|
|
2842
3532
|
...(captureClaim ? { expectedCaptureId: captureClaim.id } : {}),
|
|
2843
3533
|
providerMissingBeforeCapture: providerMissing,
|
|
3534
|
+
idleCommandContainmentMs: settings.sandboxIdleCommandContainmentMs,
|
|
3535
|
+
});
|
|
3536
|
+
// The command terminal events and agent inputs are already durable in the
|
|
3537
|
+
// cold commit; this is only best-effort live fanout.
|
|
3538
|
+
await publishDurableSessionEvents(bus, row.workspaceId, backgroundCommandEvents, (error) => {
|
|
3539
|
+
observability.warn("sandbox reaper: contained command event fanout failed", {
|
|
3540
|
+
sandboxGroupId: row.sandboxGroupId,
|
|
3541
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3542
|
+
});
|
|
2844
3543
|
});
|
|
2845
3544
|
if (wentCold) {
|
|
3545
|
+
// Only the exact successful cold commit counts provider loss. A missing
|
|
3546
|
+
// probe, a stale capture, a failed commit, or a retried child is not another
|
|
3547
|
+
// observed loss. Keep this outside the best-effort session event writer.
|
|
3548
|
+
if (providerMissing) {
|
|
3549
|
+
recordSandboxProviderMissingBeforeCapture(observability, backend);
|
|
3550
|
+
}
|
|
3551
|
+
if (lease.unobservableCommandDrainIds?.length) {
|
|
3552
|
+
recordSandboxCommandContainment(
|
|
3553
|
+
observability,
|
|
3554
|
+
providerMissing ? "provider_missing" : "contained",
|
|
3555
|
+
);
|
|
3556
|
+
}
|
|
2846
3557
|
// Durable termination record (sandbox-file-persistence observability): who
|
|
2847
3558
|
// ended this box and whether its /workspace was captured first, appended to
|
|
2848
3559
|
// every session sharing the group's box. Best-effort: attribution must
|
|
@@ -2955,18 +3666,23 @@ export async function terminateProviderBox(
|
|
|
2955
3666
|
claimedCapturePolicy?: ProviderWorkspaceCapturePolicy | null,
|
|
2956
3667
|
diskBackedArchives = false,
|
|
2957
3668
|
releaseFailedCapture?: () => Promise<void>,
|
|
3669
|
+
workspaceId?: string,
|
|
3670
|
+
beforeProviderStop?: () => Promise<void>,
|
|
2958
3671
|
): Promise<ProviderTerminationOutcome> {
|
|
2959
3672
|
const durableBackendId = (lease.resumeBackendId ?? lease.backend) as string;
|
|
2960
3673
|
const backend = sandboxBackendForSdkBackendId(durableBackendId) ?? durableBackendId;
|
|
2961
3674
|
const logIdentity = providerDrainLogIdentity(lease, backend);
|
|
3675
|
+
const warnCaptureFailure = (message: string, error: unknown): void =>
|
|
3676
|
+
warnDrainSnapshotFailure(observability, message, error, {
|
|
3677
|
+
sandboxGroupId: lease.sandboxGroupId,
|
|
3678
|
+
leaseEpoch: lease.leaseEpoch,
|
|
3679
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
3680
|
+
});
|
|
2962
3681
|
const releaseAfterCaptureFailure = async (): Promise<void> => {
|
|
2963
3682
|
try {
|
|
2964
3683
|
await releaseFailedCapture?.();
|
|
2965
3684
|
} catch (error) {
|
|
2966
|
-
|
|
2967
|
-
...logIdentity,
|
|
2968
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2969
|
-
});
|
|
3685
|
+
warnCaptureFailure("sandbox reaper: failed capture claim release failed", error);
|
|
2970
3686
|
}
|
|
2971
3687
|
};
|
|
2972
3688
|
// 'none' / no backend -> nothing to terminate.
|
|
@@ -3016,6 +3732,11 @@ export async function terminateProviderBox(
|
|
|
3016
3732
|
// fail closed instead of silently leaving a live provider behind while the
|
|
3017
3733
|
// caller commits the lease cold.
|
|
3018
3734
|
if (!lease.instanceId) {
|
|
3735
|
+
if (lease.providerCreateAttempt?.instanceId === null) {
|
|
3736
|
+
throw new Error(
|
|
3737
|
+
"provider_create_outcome_unknown: cannot infer termination from missing instance ID",
|
|
3738
|
+
);
|
|
3739
|
+
}
|
|
3019
3740
|
if (persistedInstanceId) {
|
|
3020
3741
|
throw new Error(
|
|
3021
3742
|
`sandbox backend ${backend} has persisted provider identity ${persistedInstanceId} but no authoritative lease instance; refusing teardown`,
|
|
@@ -3051,7 +3772,13 @@ export async function terminateProviderBox(
|
|
|
3051
3772
|
return { terminated: false, providerMissingBeforeCapture: false };
|
|
3052
3773
|
}
|
|
3053
3774
|
try {
|
|
3054
|
-
await
|
|
3775
|
+
await beforeProviderStop?.();
|
|
3776
|
+
await terminateModalById(
|
|
3777
|
+
settings,
|
|
3778
|
+
lease.instanceId,
|
|
3779
|
+
undefined,
|
|
3780
|
+
lease.providerCreateAttempt?.providerBindingKey,
|
|
3781
|
+
);
|
|
3055
3782
|
} catch (error) {
|
|
3056
3783
|
if (!isProviderSandboxNotFoundError("modal", error)) {
|
|
3057
3784
|
throw error;
|
|
@@ -3208,29 +3935,20 @@ export async function terminateProviderBox(
|
|
|
3208
3935
|
},
|
|
3209
3936
|
);
|
|
3210
3937
|
} catch (error) {
|
|
3211
|
-
|
|
3212
|
-
...logIdentity,
|
|
3213
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3214
|
-
});
|
|
3938
|
+
warnCaptureFailure("sandbox reaper: late workspace capture publication failed", error);
|
|
3215
3939
|
} finally {
|
|
3216
3940
|
await disposeWorkspaceArchive(archive);
|
|
3217
3941
|
}
|
|
3218
3942
|
},
|
|
3219
3943
|
observeLatePublicationFailure: (error) => {
|
|
3220
|
-
|
|
3221
|
-
...logIdentity,
|
|
3222
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3223
|
-
});
|
|
3944
|
+
warnCaptureFailure("sandbox reaper: late capture publication cleanup failed", error);
|
|
3224
3945
|
},
|
|
3225
3946
|
observeLateFailure: (error) => {
|
|
3226
3947
|
// Capture has now rejected and this timed-out path can never reach
|
|
3227
3948
|
// provider teardown. Release only our unpublished claim so a waiting
|
|
3228
3949
|
// turn can re-arm the intact live box; stale callbacks are DB-fenced.
|
|
3229
3950
|
void releaseAfterCaptureFailure();
|
|
3230
|
-
|
|
3231
|
-
...logIdentity,
|
|
3232
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3233
|
-
});
|
|
3951
|
+
warnCaptureFailure("sandbox reaper: timed-out provider capture later failed", error);
|
|
3234
3952
|
},
|
|
3235
3953
|
});
|
|
3236
3954
|
}
|
|
@@ -3241,12 +3959,9 @@ export async function terminateProviderBox(
|
|
|
3241
3959
|
});
|
|
3242
3960
|
return { terminated: true, providerMissingBeforeCapture: true };
|
|
3243
3961
|
}
|
|
3244
|
-
|
|
3962
|
+
warnCaptureFailure(
|
|
3245
3963
|
"sandbox reaper: persistWorkspace failed — leaving box draining (files NOT lost)",
|
|
3246
|
-
|
|
3247
|
-
...logIdentity,
|
|
3248
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3249
|
-
},
|
|
3964
|
+
error,
|
|
3250
3965
|
);
|
|
3251
3966
|
// An unresolved timeout is not proof of capture failure: retain the fence
|
|
3252
3967
|
// until its provider promise settles. A settled failure cannot enter the
|
|
@@ -3306,6 +4021,7 @@ export async function terminateProviderBox(
|
|
|
3306
4021
|
throw new Error(`sandbox backend ${backend} returned no terminable session state`);
|
|
3307
4022
|
}
|
|
3308
4023
|
prepareProviderForTeardownAfterCapture(backend, session);
|
|
4024
|
+
await beforeProviderStop?.();
|
|
3309
4025
|
await terminateManagedSandboxSession(client, sessionState, session);
|
|
3310
4026
|
return { terminated: true, providerMissingBeforeCapture: false };
|
|
3311
4027
|
} catch (error) {
|