@voxevolv/client 0.3.11 → 0.3.14
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/CHANGELOG.md +30 -0
- package/dist/browser/index.js +7 -7
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/index.js.sha384 +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/diagnostics.d.ts +4 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +10 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -52
- package/dist/index.js.map +3 -3
- package/dist/vendor/livekit-client.esm.mjs +66 -12
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -49,8 +49,10 @@ import {
|
|
|
49
49
|
decodeContextUpdatedPayload,
|
|
50
50
|
decodeRealtimeEvent
|
|
51
51
|
} from "./generated/protocol-codecs.js";
|
|
52
|
-
var SDK_VERSION = "0.3.
|
|
52
|
+
var SDK_VERSION = "0.3.14";
|
|
53
53
|
var CLIENT_HELLO_TIMEOUT_MS = 3e4;
|
|
54
|
+
var RECOVERY_BUFFER_STALL_MS = 5e3;
|
|
55
|
+
var RECOVERY_BUFFER_POLL_MS = 500;
|
|
54
56
|
var DIAGNOSTIC_REPORT_TIMEOUT_MS = 5e3;
|
|
55
57
|
var DEFAULT_STARTUP_ATTEMPTS = [
|
|
56
58
|
{ delayBeforeMs: 0, readyTimeoutMs: 15e3 },
|
|
@@ -58,6 +60,12 @@ var DEFAULT_STARTUP_ATTEMPTS = [
|
|
|
58
60
|
{ delayBeforeMs: 3e3, readyTimeoutMs: 5e4 }
|
|
59
61
|
];
|
|
60
62
|
var DEFAULT_STARTUP_TIMEOUT_MS = 9e4;
|
|
63
|
+
var RecoveryDataChannelStalledError = class extends Error {
|
|
64
|
+
constructor() {
|
|
65
|
+
super("Recovery data channel stopped draining before the agent handshake completed");
|
|
66
|
+
this.name = "RecoveryDataChannelStalledError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
61
69
|
var RealtimeSessionHttpError = class extends Error {
|
|
62
70
|
status;
|
|
63
71
|
code;
|
|
@@ -136,6 +144,9 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
136
144
|
endNotificationDelivered = false;
|
|
137
145
|
disconnectMode = null;
|
|
138
146
|
activeRecovery = null;
|
|
147
|
+
recoveryStartedAt = null;
|
|
148
|
+
livekitResumeController = null;
|
|
149
|
+
terminalReplayController = null;
|
|
139
150
|
autoRejoinPromise = null;
|
|
140
151
|
autoRejoinController = null;
|
|
141
152
|
manualRejoinController = null;
|
|
@@ -156,6 +167,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
156
167
|
lifecycleCredentials;
|
|
157
168
|
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
158
169
|
activeContextTransfers = 0;
|
|
170
|
+
contextTransferControllers = /* @__PURE__ */ new Set();
|
|
159
171
|
activeContextBytes = 0;
|
|
160
172
|
diagnosticLoopTimer;
|
|
161
173
|
diagnosticLoopExpectedAt = 0;
|
|
@@ -614,7 +626,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
614
626
|
this.apiBase = apiBase;
|
|
615
627
|
this.agentReady = false;
|
|
616
628
|
try {
|
|
617
|
-
await this.
|
|
629
|
+
await this.measureRecoveryStage(
|
|
630
|
+
"room_connect",
|
|
631
|
+
() => this.connectRoom(signal, recovering),
|
|
632
|
+
recovering
|
|
633
|
+
);
|
|
618
634
|
throwIfAborted(signal);
|
|
619
635
|
if (audioTrack) {
|
|
620
636
|
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
@@ -626,10 +642,20 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
626
642
|
}
|
|
627
643
|
throwIfAborted(signal);
|
|
628
644
|
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
629
|
-
await raceWithAbort(this.sendHello(), signal);
|
|
645
|
+
await this.measureRecoveryStage("hello", () => recovering ? this.sendRecoveryHello(signal) : raceWithAbort(this.sendHello(), signal), recovering);
|
|
630
646
|
const readyDeadline = Date.now() + readyTimeoutMs;
|
|
631
|
-
await this.
|
|
632
|
-
|
|
647
|
+
await this.measureRecoveryStage(
|
|
648
|
+
"agent_ready",
|
|
649
|
+
() => this.waitForAgentReady(readyTimeoutMs, signal),
|
|
650
|
+
recovering
|
|
651
|
+
);
|
|
652
|
+
if (waitForRemoteAudio) {
|
|
653
|
+
await this.measureRecoveryStage(
|
|
654
|
+
"remote_audio",
|
|
655
|
+
() => this.waitForRemoteAudioTrack(readyDeadline, signal),
|
|
656
|
+
recovering
|
|
657
|
+
);
|
|
658
|
+
}
|
|
633
659
|
throwIfAborted(signal);
|
|
634
660
|
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
635
661
|
throw new Error("Connection restoration interrupted");
|
|
@@ -731,11 +757,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
731
757
|
this.contextGeneration += 1;
|
|
732
758
|
this.replacementContext.pause();
|
|
733
759
|
this.rejectPendingCommands(new Error("Restoring the session"));
|
|
734
|
-
let response = await this.fetchRoomToken(apiBase, signal);
|
|
760
|
+
let response = await this.fetchRoomToken(apiBase, signal, recovering);
|
|
735
761
|
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
736
762
|
await this.refreshCredentials(signal);
|
|
737
763
|
throwIfAborted(signal);
|
|
738
|
-
response = await this.fetchRoomToken(apiBase, signal);
|
|
764
|
+
response = await this.fetchRoomToken(apiBase, signal, recovering);
|
|
739
765
|
}
|
|
740
766
|
if (this.sessionEnded) {
|
|
741
767
|
if (this.terminalSessionError) throw this.terminalSessionError;
|
|
@@ -751,7 +777,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
751
777
|
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
752
778
|
this.disconnectMode = "rejoin";
|
|
753
779
|
try {
|
|
754
|
-
await
|
|
780
|
+
await this.measureRecoveryStage(
|
|
781
|
+
"room_disconnect",
|
|
782
|
+
() => raceWithAbort(this.room.disconnect(), signal),
|
|
783
|
+
recovering
|
|
784
|
+
);
|
|
755
785
|
} finally {
|
|
756
786
|
this.disconnectMode = null;
|
|
757
787
|
}
|
|
@@ -800,6 +830,8 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
800
830
|
}
|
|
801
831
|
async sendContextUpdate(update, timeoutMs) {
|
|
802
832
|
const generation = this.contextGeneration;
|
|
833
|
+
const controller = new AbortController();
|
|
834
|
+
this.contextTransferControllers.add(controller);
|
|
803
835
|
const payloadBytes = contextUpdateBytes(update);
|
|
804
836
|
const transferStartedAt = Date.now();
|
|
805
837
|
let transferTimedOut = false;
|
|
@@ -811,7 +843,8 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
811
843
|
});
|
|
812
844
|
this.activeContextTransfers += 1;
|
|
813
845
|
this.activeContextBytes += payloadBytes;
|
|
814
|
-
const uploading = ("image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update)).finally(() => {
|
|
846
|
+
const uploading = ("image" in update ? this.uploadImageContext(update, controller.signal) : this.uploadTextContext(update, controller.signal)).finally(() => {
|
|
847
|
+
this.contextTransferControllers.delete(controller);
|
|
815
848
|
this.activeContextTransfers -= 1;
|
|
816
849
|
this.activeContextBytes -= payloadBytes;
|
|
817
850
|
});
|
|
@@ -835,6 +868,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
835
868
|
timeout_ms: timeoutMs
|
|
836
869
|
});
|
|
837
870
|
this.flushDiagnostics("context_transfer_timeout");
|
|
871
|
+
controller.abort(error);
|
|
838
872
|
reject(error);
|
|
839
873
|
}, timeoutMs);
|
|
840
874
|
});
|
|
@@ -891,7 +925,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
891
925
|
void this.replacementContext.resume(restore).catch(() => {
|
|
892
926
|
});
|
|
893
927
|
}
|
|
894
|
-
async uploadTextContext(update) {
|
|
928
|
+
async uploadTextContext(update, signal) {
|
|
895
929
|
const data = new TextEncoder().encode(update.text);
|
|
896
930
|
if (data.byteLength === 0) throw new TypeError("Context text cannot be empty");
|
|
897
931
|
if (data.byteLength > 262144) {
|
|
@@ -906,17 +940,20 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
906
940
|
if (data.byteLength <= 32768) return { payload: { ...base, text: update.text } };
|
|
907
941
|
const sha256 = await sha256Hex(data);
|
|
908
942
|
const streamId = contextStreamId();
|
|
909
|
-
const send = async () => {
|
|
910
|
-
|
|
943
|
+
const send = async (signal2) => {
|
|
944
|
+
signal2.throwIfAborted();
|
|
945
|
+
const options = {
|
|
911
946
|
streamId,
|
|
912
947
|
topic: "voxevolv.context.text",
|
|
913
948
|
totalSize: data.byteLength,
|
|
914
|
-
attributes: { sha256 }
|
|
915
|
-
|
|
949
|
+
attributes: { sha256 },
|
|
950
|
+
signal: signal2
|
|
951
|
+
};
|
|
952
|
+
const writer = await this.room.localParticipant.streamText(options);
|
|
916
953
|
await writer.write(update.text);
|
|
917
954
|
await writer.close();
|
|
918
955
|
};
|
|
919
|
-
await send();
|
|
956
|
+
await send(signal);
|
|
920
957
|
return {
|
|
921
958
|
payload: {
|
|
922
959
|
...base,
|
|
@@ -925,7 +962,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
925
962
|
resend: send
|
|
926
963
|
};
|
|
927
964
|
}
|
|
928
|
-
async uploadImageContext(update) {
|
|
965
|
+
async uploadImageContext(update, signal) {
|
|
929
966
|
const allowed = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
930
967
|
if (!allowed.has(update.image.type)) {
|
|
931
968
|
throw new TypeError("Image context must be JPEG, PNG, or WebP");
|
|
@@ -936,19 +973,22 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
936
973
|
}
|
|
937
974
|
const sha256 = await sha256Hex(data);
|
|
938
975
|
const streamId = contextStreamId();
|
|
939
|
-
const send = async () => {
|
|
940
|
-
|
|
976
|
+
const send = async (signal2) => {
|
|
977
|
+
signal2.throwIfAborted();
|
|
978
|
+
const options = {
|
|
941
979
|
streamId,
|
|
942
980
|
topic: "voxevolv.context.image",
|
|
943
981
|
name: "context-image",
|
|
944
982
|
mimeType: update.image.type,
|
|
945
983
|
totalSize: data.byteLength,
|
|
946
|
-
attributes: { sha256 }
|
|
947
|
-
|
|
984
|
+
attributes: { sha256 },
|
|
985
|
+
signal: signal2
|
|
986
|
+
};
|
|
987
|
+
const writer = await this.room.localParticipant.streamBytes(options);
|
|
948
988
|
await writer.write(data);
|
|
949
989
|
await writer.close();
|
|
950
990
|
};
|
|
951
|
-
await send();
|
|
991
|
+
await send(signal);
|
|
952
992
|
return {
|
|
953
993
|
payload: {
|
|
954
994
|
key: update.key,
|
|
@@ -1051,16 +1091,26 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1051
1091
|
bindRoomEvents() {
|
|
1052
1092
|
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
1053
1093
|
if (participant.isAgent && !this.disconnectMode) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1094
|
+
queueMicrotask(() => {
|
|
1095
|
+
if (this.disconnectMode || this.terminalSessionError || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
|
|
1096
|
+
if (this.sessionEnded) {
|
|
1097
|
+
this.agentDisconnected = true;
|
|
1098
|
+
this.finishPendingEnd();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (!this.hasConnectedValue || this.room.state === LiveKitConnectionState.Reconnecting || this.activeRecovery !== null || this.autoRejoinPromise !== null || this.manualRejoinController !== null) return;
|
|
1102
|
+
if ([...this.room.remoteParticipants.values()].some((remote) => remote.isAgent)) return;
|
|
1103
|
+
this.agentDisconnected = true;
|
|
1104
|
+
if (!this.shouldAutoRejoin()) {
|
|
1105
|
+
void this.recoverDisconnectedSession().catch(() => {
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
this.startAutoRejoin(
|
|
1110
|
+
new Error("Agent disconnected from the room"),
|
|
1111
|
+
{ trigger: "agent_disconnected" }
|
|
1112
|
+
);
|
|
1113
|
+
});
|
|
1064
1114
|
}
|
|
1065
1115
|
});
|
|
1066
1116
|
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
@@ -1072,7 +1122,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1072
1122
|
this.handleLiveKitDisconnected(reason);
|
|
1073
1123
|
});
|
|
1074
1124
|
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
1075
|
-
if (this.sessionEnded || this.terminalSessionError) return;
|
|
1125
|
+
if (this.sessionEnded || this.terminalSessionError || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
|
|
1076
1126
|
if (state === LiveKitConnectionState.Connected) {
|
|
1077
1127
|
const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
|
|
1078
1128
|
this.setState(
|
|
@@ -1149,13 +1199,15 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1149
1199
|
});
|
|
1150
1200
|
}
|
|
1151
1201
|
handleLiveKitReconnecting() {
|
|
1152
|
-
if (this.sessionEnded || this.disconnectMode === "intentional") return;
|
|
1202
|
+
if (this.sessionEnded || this.disconnectMode === "intentional" || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
|
|
1153
1203
|
this.agentReady = false;
|
|
1154
1204
|
if (this.autoRejoinPromise !== null || this.activeRecovery?.strategy === "rejoin" || this.disconnectMode === "rejoin") {
|
|
1155
1205
|
this.setState("resuming");
|
|
1156
1206
|
return;
|
|
1157
1207
|
}
|
|
1158
1208
|
this.contextGeneration += 1;
|
|
1209
|
+
this.livekitResumeController?.abort(new Error("LiveKit connection interrupted again"));
|
|
1210
|
+
this.livekitResumeController = null;
|
|
1159
1211
|
this.replacementContext.pause();
|
|
1160
1212
|
this.reconnectStartedAt ??= Date.now();
|
|
1161
1213
|
this.rejectPendingCommands(
|
|
@@ -1164,12 +1216,15 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1164
1216
|
);
|
|
1165
1217
|
if (this.activeRecovery === null) {
|
|
1166
1218
|
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1219
|
+
this.recordDiagnostic({ operation: "connection_recovery", stage: "room_connect", status: "started" });
|
|
1167
1220
|
}
|
|
1168
1221
|
this.setState("reconnecting");
|
|
1169
1222
|
}
|
|
1170
1223
|
async handleLiveKitReconnected() {
|
|
1171
|
-
if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise) return;
|
|
1224
|
+
if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise || this.livekitResumeController || this.terminalReplayController || ["disconnected", "failed"].includes(this.stateValue)) return;
|
|
1172
1225
|
const generation = this.contextGeneration;
|
|
1226
|
+
const controller = new AbortController();
|
|
1227
|
+
this.livekitResumeController = controller;
|
|
1173
1228
|
const recoveredAt = Date.now();
|
|
1174
1229
|
if (this.reconnectStartedAt !== null) {
|
|
1175
1230
|
this.pendingReconnect = {
|
|
@@ -1182,21 +1237,35 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1182
1237
|
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
1183
1238
|
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1184
1239
|
}
|
|
1240
|
+
this.recordDiagnostic({
|
|
1241
|
+
operation: "connection_recovery",
|
|
1242
|
+
stage: "room_connect",
|
|
1243
|
+
status: "succeeded",
|
|
1244
|
+
duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0)
|
|
1245
|
+
});
|
|
1185
1246
|
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
1186
1247
|
this.clientSeq = 0;
|
|
1187
1248
|
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
1188
1249
|
try {
|
|
1189
|
-
await this.
|
|
1250
|
+
await this.measureRecoveryStage("hello", () => this.sendRecoveryHello(controller.signal));
|
|
1190
1251
|
const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
|
|
1191
|
-
await this.
|
|
1192
|
-
|
|
1252
|
+
await this.measureRecoveryStage(
|
|
1253
|
+
"agent_ready",
|
|
1254
|
+
() => this.waitForAgentReady(Math.max(deadline - Date.now(), 0), controller.signal)
|
|
1255
|
+
);
|
|
1256
|
+
if (this.recoveryReadiness.waitForRemoteAudio) {
|
|
1257
|
+
await this.measureRecoveryStage(
|
|
1258
|
+
"remote_audio",
|
|
1259
|
+
() => this.waitForRemoteAudioTrack(deadline, controller.signal)
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1193
1262
|
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1194
1263
|
this.resumeContextDelivery(true);
|
|
1195
1264
|
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1196
1265
|
this.setState("connected");
|
|
1197
1266
|
this.completeRecovery();
|
|
1198
1267
|
} catch (error) {
|
|
1199
|
-
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1268
|
+
if (controller.signal.aborted || this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1200
1269
|
const normalizedError = toError(error);
|
|
1201
1270
|
if (this.shouldAutoRejoin()) {
|
|
1202
1271
|
this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
|
|
@@ -1204,6 +1273,8 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1204
1273
|
this.failRecovery(normalizedError);
|
|
1205
1274
|
this.setState("failed");
|
|
1206
1275
|
}
|
|
1276
|
+
} finally {
|
|
1277
|
+
if (this.livekitResumeController === controller) this.livekitResumeController = null;
|
|
1207
1278
|
}
|
|
1208
1279
|
}
|
|
1209
1280
|
handleLiveKitDisconnected(reason) {
|
|
@@ -1223,7 +1294,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1223
1294
|
this.setState("disconnected");
|
|
1224
1295
|
return;
|
|
1225
1296
|
}
|
|
1226
|
-
if (this.terminalSessionError) return;
|
|
1297
|
+
if (this.terminalSessionError || this.terminalReplayController) return;
|
|
1227
1298
|
if (this.shouldAutoRejoin(reason)) {
|
|
1228
1299
|
this.startAutoRejoin(
|
|
1229
1300
|
new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`),
|
|
@@ -1231,6 +1302,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1231
1302
|
);
|
|
1232
1303
|
return;
|
|
1233
1304
|
}
|
|
1305
|
+
if (this.hasConnectedValue && !this.sessionEnded && !["disconnected", "failed"].includes(this.stateValue) && (!isTerminalDisconnectReason(reason) || reason === DisconnectReason.ROOM_CLOSED || reason === DisconnectReason.ROOM_DELETED)) {
|
|
1306
|
+
void this.recoverDisconnectedSession().catch(() => {
|
|
1307
|
+
});
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1234
1310
|
if (this.activeRecovery !== null) {
|
|
1235
1311
|
this.failRecovery(
|
|
1236
1312
|
new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`)
|
|
@@ -1252,14 +1328,42 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1252
1328
|
void this.disconnect().catch(() => {
|
|
1253
1329
|
});
|
|
1254
1330
|
}
|
|
1255
|
-
async
|
|
1331
|
+
async recoverDisconnectedSession() {
|
|
1332
|
+
if (this.terminalReplayController) return;
|
|
1333
|
+
this.failRecovery(new Error("Transport closed during recovery"));
|
|
1334
|
+
this.cancelRecovery();
|
|
1335
|
+
this.contextGeneration += 1;
|
|
1336
|
+
this.replacementContext.pause();
|
|
1337
|
+
const controller = new AbortController();
|
|
1338
|
+
this.terminalReplayController = controller;
|
|
1339
|
+
this.setState("resuming");
|
|
1340
|
+
try {
|
|
1341
|
+
await this.recoverSessionEnd(this.apiBase, controller.signal, true);
|
|
1342
|
+
} finally {
|
|
1343
|
+
if (this.terminalReplayController === controller) {
|
|
1344
|
+
this.terminalReplayController = null;
|
|
1345
|
+
if (!controller.signal.aborted && !this.sessionEnded && !this.terminalSessionError) {
|
|
1346
|
+
await this.disconnect();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async recoverSessionEnd(apiBase, signal, waitForCommit = false) {
|
|
1256
1352
|
const controller = new AbortController();
|
|
1257
1353
|
const cancel = () => controller.abort(signal?.reason);
|
|
1258
1354
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
1259
1355
|
if (signal?.aborted) cancel();
|
|
1260
1356
|
const timeout = globalThis.setTimeout(() => controller.abort(), 5e3);
|
|
1261
1357
|
try {
|
|
1262
|
-
|
|
1358
|
+
do {
|
|
1359
|
+
try {
|
|
1360
|
+
await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
if (!waitForCommit || controller.signal.aborted || isTerminalRejoinError(error)) throw error;
|
|
1363
|
+
}
|
|
1364
|
+
if (!waitForCommit || this.sessionEnded || this.terminalSessionError) break;
|
|
1365
|
+
await abortableDelay(500, controller.signal);
|
|
1366
|
+
} while (!controller.signal.aborted);
|
|
1263
1367
|
} catch {
|
|
1264
1368
|
} finally {
|
|
1265
1369
|
globalThis.clearTimeout(timeout);
|
|
@@ -1275,6 +1379,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1275
1379
|
this.notifyListeners(this.endedListeners, event);
|
|
1276
1380
|
}
|
|
1277
1381
|
beginRecovery(strategy, attempt, details) {
|
|
1382
|
+
this.recoveryStartedAt ??= Date.now();
|
|
1278
1383
|
this.activeRecovery = { strategy, attempt, details };
|
|
1279
1384
|
this.recordDiagnostic({
|
|
1280
1385
|
operation: "connection_recovery",
|
|
@@ -1282,7 +1387,8 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1282
1387
|
reason: details.trigger,
|
|
1283
1388
|
message_id: details.messageId,
|
|
1284
1389
|
context_key: details.contextKey,
|
|
1285
|
-
timeout_ms: details.timeoutMs
|
|
1390
|
+
timeout_ms: details.timeoutMs,
|
|
1391
|
+
retry_count: attempt - 1
|
|
1286
1392
|
});
|
|
1287
1393
|
this.flushDiagnostics("recovery_started");
|
|
1288
1394
|
this.notifyListeners(this.recoveryListeners, {
|
|
@@ -1295,6 +1401,16 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1295
1401
|
completeRecovery() {
|
|
1296
1402
|
const recovery = this.activeRecovery;
|
|
1297
1403
|
if (!recovery) return;
|
|
1404
|
+
this.recordDiagnostic({
|
|
1405
|
+
operation: "connection_recovery",
|
|
1406
|
+
stage: "complete",
|
|
1407
|
+
status: "succeeded",
|
|
1408
|
+
duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0),
|
|
1409
|
+
retry_count: recovery.attempt - 1,
|
|
1410
|
+
reason: recovery.details.trigger
|
|
1411
|
+
});
|
|
1412
|
+
this.flushDiagnostics("recovery_completed");
|
|
1413
|
+
this.recoveryStartedAt = null;
|
|
1298
1414
|
this.activeRecovery = null;
|
|
1299
1415
|
this.notifyListeners(this.recoveryListeners, {
|
|
1300
1416
|
status: "succeeded",
|
|
@@ -1306,6 +1422,15 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1306
1422
|
failRecovery(error) {
|
|
1307
1423
|
const recovery = this.activeRecovery;
|
|
1308
1424
|
if (!recovery) return;
|
|
1425
|
+
this.recordDiagnostic({
|
|
1426
|
+
operation: "connection_recovery",
|
|
1427
|
+
stage: "complete",
|
|
1428
|
+
status: "failed",
|
|
1429
|
+
duration_ms: Math.max(Date.now() - (this.recoveryStartedAt ?? Date.now()), 0),
|
|
1430
|
+
retry_count: recovery.attempt - 1,
|
|
1431
|
+
reason: recovery.details.trigger
|
|
1432
|
+
});
|
|
1433
|
+
this.flushDiagnostics("recovery_failed");
|
|
1309
1434
|
this.activeRecovery = null;
|
|
1310
1435
|
this.notifyListeners(this.recoveryListeners, {
|
|
1311
1436
|
status: "failed",
|
|
@@ -1320,13 +1445,19 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1320
1445
|
}
|
|
1321
1446
|
startAutoRejoin(initialError, details) {
|
|
1322
1447
|
if (this.autoRejoinPromise || !this.shouldAutoRejoin()) return;
|
|
1448
|
+
this.recoveryStartedAt ??= Date.now();
|
|
1323
1449
|
this.agentReady = false;
|
|
1324
1450
|
this.contextGeneration += 1;
|
|
1451
|
+
this.livekitResumeController?.abort(initialError);
|
|
1325
1452
|
this.replacementContext.pause();
|
|
1326
1453
|
this.rejectPendingCommands(initialError);
|
|
1327
1454
|
this.reconnectStartedAt ??= Date.now();
|
|
1328
1455
|
const controller = new AbortController();
|
|
1329
1456
|
this.autoRejoinController = controller;
|
|
1457
|
+
const remainingMs = Math.max(
|
|
1458
|
+
this.autoRejoinOptions.timeoutMs - (Date.now() - this.recoveryStartedAt),
|
|
1459
|
+
0
|
|
1460
|
+
);
|
|
1330
1461
|
const timeout = globalThis.setTimeout(() => {
|
|
1331
1462
|
if (controller.signal.aborted) return;
|
|
1332
1463
|
const error = new Error("Session recovery timed out");
|
|
@@ -1336,7 +1467,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1336
1467
|
this.replacementContext.clear(error);
|
|
1337
1468
|
this.failRecovery(error);
|
|
1338
1469
|
this.setState("failed");
|
|
1339
|
-
},
|
|
1470
|
+
}, remainingMs);
|
|
1340
1471
|
const promise = this.runAutoRejoin(initialError, controller.signal, details).finally(() => {
|
|
1341
1472
|
globalThis.clearTimeout(timeout);
|
|
1342
1473
|
if (this.autoRejoinController === controller) {
|
|
@@ -1382,6 +1513,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1382
1513
|
this.setState("failed");
|
|
1383
1514
|
}
|
|
1384
1515
|
cancelRecovery() {
|
|
1516
|
+
this.terminalReplayController?.abort();
|
|
1517
|
+
this.terminalReplayController = null;
|
|
1518
|
+
this.livekitResumeController?.abort();
|
|
1519
|
+
this.livekitResumeController = null;
|
|
1520
|
+
this.recoveryStartedAt = null;
|
|
1385
1521
|
this.autoRejoinController?.abort();
|
|
1386
1522
|
this.autoRejoinController = null;
|
|
1387
1523
|
this.manualRejoinController?.abort();
|
|
@@ -1419,19 +1555,71 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1419
1555
|
throw new SessionConfigurationError("audioTrack has already ended");
|
|
1420
1556
|
}
|
|
1421
1557
|
}
|
|
1422
|
-
async
|
|
1558
|
+
async measureRecoveryStage(stage, operation, recovering = true) {
|
|
1559
|
+
if (!recovering) return operation();
|
|
1560
|
+
const startedAt = Date.now();
|
|
1561
|
+
this.recordDiagnostic({ operation: "connection_recovery", stage, status: "started" });
|
|
1562
|
+
try {
|
|
1563
|
+
const result = await operation();
|
|
1564
|
+
this.recordDiagnostic({
|
|
1565
|
+
operation: "connection_recovery",
|
|
1566
|
+
stage,
|
|
1567
|
+
status: "succeeded",
|
|
1568
|
+
duration_ms: Date.now() - startedAt
|
|
1569
|
+
});
|
|
1570
|
+
return result;
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
this.recordDiagnostic({
|
|
1573
|
+
operation: "connection_recovery",
|
|
1574
|
+
stage,
|
|
1575
|
+
status: "failed",
|
|
1576
|
+
duration_ms: Date.now() - startedAt,
|
|
1577
|
+
reason: error instanceof RecoveryDataChannelStalledError ? "data_channel_stalled" : "stage_failed"
|
|
1578
|
+
});
|
|
1579
|
+
this.flushDiagnostics("recovery_stage_failed");
|
|
1580
|
+
throw error;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
async fetchRoomToken(apiBase, signal, recovering = true) {
|
|
1423
1584
|
const controller = new AbortController();
|
|
1424
1585
|
const cancel = () => controller.abort(signal?.reason);
|
|
1425
1586
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
1426
1587
|
const timeout = globalThis.setTimeout(() => controller.abort(), 1e4);
|
|
1588
|
+
const startedAt = Date.now();
|
|
1589
|
+
if (recovering) {
|
|
1590
|
+
this.recordDiagnostic({ operation: "connection_recovery", stage: "token_request", status: "started" });
|
|
1591
|
+
}
|
|
1427
1592
|
try {
|
|
1428
1593
|
throwIfAborted(signal);
|
|
1429
|
-
|
|
1594
|
+
const response = await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
1430
1595
|
method: "POST",
|
|
1431
1596
|
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1432
1597
|
credentials: this.lifecycleCredentials,
|
|
1433
1598
|
signal: controller.signal
|
|
1434
1599
|
}), controller.signal);
|
|
1600
|
+
if (recovering) {
|
|
1601
|
+
this.recordDiagnostic({
|
|
1602
|
+
operation: "connection_recovery",
|
|
1603
|
+
stage: "token_request",
|
|
1604
|
+
status: response.ok ? "succeeded" : "failed",
|
|
1605
|
+
http_status: response.status,
|
|
1606
|
+
duration_ms: Date.now() - startedAt
|
|
1607
|
+
});
|
|
1608
|
+
if (!response.ok) this.flushDiagnostics("recovery_stage_failed");
|
|
1609
|
+
}
|
|
1610
|
+
return response;
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
if (recovering) {
|
|
1613
|
+
this.recordDiagnostic({
|
|
1614
|
+
operation: "connection_recovery",
|
|
1615
|
+
stage: "token_request",
|
|
1616
|
+
status: "failed",
|
|
1617
|
+
duration_ms: Date.now() - startedAt,
|
|
1618
|
+
reason: signal?.aborted ? "cancelled" : controller.signal.aborted ? "request_timeout" : "request_failed"
|
|
1619
|
+
});
|
|
1620
|
+
this.flushDiagnostics("recovery_stage_failed");
|
|
1621
|
+
}
|
|
1622
|
+
throw error;
|
|
1435
1623
|
} finally {
|
|
1436
1624
|
globalThis.clearTimeout(timeout);
|
|
1437
1625
|
signal?.removeEventListener("abort", cancel);
|
|
@@ -1563,6 +1751,36 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1563
1751
|
await this.sendTrackedCommand(message, CLIENT_HELLO_TIMEOUT_MS);
|
|
1564
1752
|
if (this.pendingReconnect === reconnect) this.pendingReconnect = null;
|
|
1565
1753
|
}
|
|
1754
|
+
async sendRecoveryHello(signal) {
|
|
1755
|
+
throwIfAborted(signal);
|
|
1756
|
+
let previousBytes = this.reliableDataChannelBufferedBytes();
|
|
1757
|
+
let lastProgressAt = Date.now();
|
|
1758
|
+
let timer;
|
|
1759
|
+
const stalled = new Promise((_resolve, reject) => {
|
|
1760
|
+
const checkProgress = () => {
|
|
1761
|
+
const bufferedBytes = this.reliableDataChannelBufferedBytes();
|
|
1762
|
+
if (bufferedBytes === void 0 || bufferedBytes === 0 || previousBytes === void 0 || previousBytes === 0 || bufferedBytes < previousBytes) {
|
|
1763
|
+
lastProgressAt = Date.now();
|
|
1764
|
+
} else if (Date.now() - lastProgressAt >= RECOVERY_BUFFER_STALL_MS) {
|
|
1765
|
+
reject(new RecoveryDataChannelStalledError());
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
previousBytes = bufferedBytes;
|
|
1769
|
+
timer = globalThis.setTimeout(checkProgress, RECOVERY_BUFFER_POLL_MS);
|
|
1770
|
+
};
|
|
1771
|
+
timer = globalThis.setTimeout(checkProgress, RECOVERY_BUFFER_POLL_MS);
|
|
1772
|
+
});
|
|
1773
|
+
try {
|
|
1774
|
+
await raceWithAbort(Promise.race([this.sendHello(), stalled]), signal);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
if (error instanceof RecoveryDataChannelStalledError) {
|
|
1777
|
+
this.rejectPendingCommands(error);
|
|
1778
|
+
}
|
|
1779
|
+
throw error;
|
|
1780
|
+
} finally {
|
|
1781
|
+
if (timer !== void 0) globalThis.clearTimeout(timer);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1566
1784
|
async sendTrackedCommand(message, timeoutMs, retryTransfer) {
|
|
1567
1785
|
const contextKey = contextKeyFromMessage(message);
|
|
1568
1786
|
const payloadBytes = contextPayloadBytes(message.payload);
|
|
@@ -1579,10 +1797,14 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1579
1797
|
timeout_ms: timeoutMs,
|
|
1580
1798
|
event_type: diagnosticEventType(message.type)
|
|
1581
1799
|
});
|
|
1800
|
+
const sendController = message.type === "context.update" ? new AbortController() : void 0;
|
|
1582
1801
|
let markSettled = () => {
|
|
1583
1802
|
};
|
|
1584
1803
|
const settled = new Promise((resolve) => {
|
|
1585
|
-
markSettled =
|
|
1804
|
+
markSettled = () => {
|
|
1805
|
+
sendController?.abort();
|
|
1806
|
+
resolve();
|
|
1807
|
+
};
|
|
1586
1808
|
});
|
|
1587
1809
|
const response = new Promise((resolve, reject) => {
|
|
1588
1810
|
const timeout = globalThis.setTimeout(() => {
|
|
@@ -1625,6 +1847,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1625
1847
|
settled,
|
|
1626
1848
|
markSettled,
|
|
1627
1849
|
retryTransfer,
|
|
1850
|
+
sendController,
|
|
1628
1851
|
transferRetrying: false,
|
|
1629
1852
|
transferRetryRequested: false
|
|
1630
1853
|
}
|
|
@@ -1648,10 +1871,10 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1648
1871
|
if (!pending) return;
|
|
1649
1872
|
if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue))) return;
|
|
1650
1873
|
try {
|
|
1651
|
-
if (pending.transferRetryRequested && pending.retryTransfer) {
|
|
1874
|
+
if (pending.transferRetryRequested && pending.retryTransfer && pending.sendController) {
|
|
1652
1875
|
pending.transferRetryRequested = false;
|
|
1653
1876
|
pending.transferRetrying = true;
|
|
1654
|
-
const transfer = pending.retryTransfer();
|
|
1877
|
+
const transfer = pending.retryTransfer(pending.sendController.signal);
|
|
1655
1878
|
pending.transferSettled = transfer.then(() => {
|
|
1656
1879
|
}, () => {
|
|
1657
1880
|
});
|
|
@@ -1698,12 +1921,13 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1698
1921
|
const attemptStartedAt = Date.now();
|
|
1699
1922
|
try {
|
|
1700
1923
|
let publishFinished = false;
|
|
1701
|
-
const publish = this.publishCommand(message).then(() => {
|
|
1924
|
+
const publish = this.publishCommand(message, pending.sendController?.signal).then(() => {
|
|
1702
1925
|
publishFinished = true;
|
|
1703
1926
|
});
|
|
1704
1927
|
await Promise.race([publish, pending.settled]);
|
|
1705
1928
|
if (!publishFinished) return;
|
|
1706
1929
|
} catch (error) {
|
|
1930
|
+
if (pending.sendController?.signal.aborted) return;
|
|
1707
1931
|
this.recordDiagnostic({
|
|
1708
1932
|
operation: "command_send",
|
|
1709
1933
|
status: "failed",
|
|
@@ -1735,10 +1959,11 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1735
1959
|
});
|
|
1736
1960
|
if (durationMs > 2e3) this.flushDiagnostics("slow_operation");
|
|
1737
1961
|
}
|
|
1738
|
-
async publishCommand(message) {
|
|
1962
|
+
async publishCommand(message, signal) {
|
|
1963
|
+
const options = { reliable: true, topic: "voxevolv.commands", signal };
|
|
1739
1964
|
await this.room.localParticipant.publishData(
|
|
1740
1965
|
new TextEncoder().encode(JSON.stringify(message)),
|
|
1741
|
-
|
|
1966
|
+
options
|
|
1742
1967
|
);
|
|
1743
1968
|
}
|
|
1744
1969
|
startDiagnosticLoopMonitor() {
|
|
@@ -1757,7 +1982,7 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1757
1982
|
connection_state: this.stateValue,
|
|
1758
1983
|
buffered_bytes: this.reliableDataChannelBufferedBytes()
|
|
1759
1984
|
};
|
|
1760
|
-
if (entry.operation === "tool_rpc") {
|
|
1985
|
+
if (entry.operation === "tool_rpc" || entry.operation === "connection_recovery") {
|
|
1761
1986
|
snapshot.page_visibility = globalThis.document?.visibilityState;
|
|
1762
1987
|
snapshot.active_context_transfers = this.activeContextTransfers;
|
|
1763
1988
|
snapshot.active_context_bytes = this.activeContextBytes;
|
|
@@ -1933,6 +2158,9 @@ var RealtimeSession = class _RealtimeSession {
|
|
|
1933
2158
|
}
|
|
1934
2159
|
}
|
|
1935
2160
|
rejectPendingCommands(error, preserveContext = false) {
|
|
2161
|
+
if (!preserveContext) {
|
|
2162
|
+
for (const controller of this.contextTransferControllers) controller.abort(error);
|
|
2163
|
+
}
|
|
1936
2164
|
for (const [messageId, pending] of this.pendingCommands) {
|
|
1937
2165
|
if (preserveContext && pending.commandType === "context.update") continue;
|
|
1938
2166
|
this.recordCommandResult(
|