@teamlearners/clawops 0.40.0 → 0.42.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/agent/index.cjs +244 -82
- package/dist/agent/index.cjs.map +1 -1
- package/dist/agent/index.d.cts +87 -3
- package/dist/agent/index.d.ts +87 -3
- package/dist/agent/index.js +177 -15
- package/dist/agent/index.js.map +1 -1
- package/dist/agent/livekit/index.cjs +9 -9
- package/dist/agent/livekit/index.js +2 -2
- package/dist/chunk-FQ5SY7WN.cjs +8 -0
- package/dist/{chunk-IEFCBBKV.cjs.map → chunk-FQ5SY7WN.cjs.map} +1 -1
- package/dist/{chunk-AYSW63CJ.js → chunk-HJLF7U7U.js} +3 -3
- package/dist/{chunk-AYSW63CJ.js.map → chunk-HJLF7U7U.js.map} +1 -1
- package/dist/{chunk-PO5ACGWL.cjs → chunk-MQXG2CWP.cjs} +4 -4
- package/dist/{chunk-PO5ACGWL.cjs.map → chunk-MQXG2CWP.cjs.map} +1 -1
- package/dist/chunk-XENRMYVS.js +6 -0
- package/dist/{chunk-R6UEIVYE.js.map → chunk-XENRMYVS.js.map} +1 -1
- package/dist/{client-DOMZ2phc.d.cts → client-DfN4wQXU.d.cts} +80 -20
- package/dist/{client-DOMZ2phc.d.ts → client-DfN4wQXU.d.ts} +80 -20
- package/dist/index.cjs +30 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +29 -18
- package/dist/index.js.map +1 -1
- package/dist/solapi/index.d.cts +2 -2
- package/dist/solapi/index.d.ts +2 -2
- package/package.json +1 -1
- package/dist/chunk-IEFCBBKV.cjs +0 -8
- package/dist/chunk-R6UEIVYE.js +0 -6
package/dist/agent/index.d.cts
CHANGED
|
@@ -127,6 +127,16 @@ interface ClawOpsAgentOptions {
|
|
|
127
127
|
logger?: Logger;
|
|
128
128
|
/** Tool 실행 관련 설정. */
|
|
129
129
|
toolConfig?: ToolConfig;
|
|
130
|
+
/**
|
|
131
|
+
* Called when another process takes over this number's control connection — the normal
|
|
132
|
+
* middle of a rolling deploy, seen from the instance being replaced. New calls already go
|
|
133
|
+
* elsewhere; finish the calls still in flight and exit. `serve()` handles this for you;
|
|
134
|
+
* wire this only if you drive `connect()` yourself, and call `drain()` from it.
|
|
135
|
+
*/
|
|
136
|
+
onTakenOver?: (info: {
|
|
137
|
+
code: number;
|
|
138
|
+
reason: string;
|
|
139
|
+
}) => void;
|
|
130
140
|
/**
|
|
131
141
|
* Gain applied to inbound audio (caller → AI). 1.0 = pass-through (default), 0 = mute, 2.0 = 2x amplify.
|
|
132
142
|
* AI/STT receive the gained audio, and recording captures it post-gain.
|
|
@@ -164,6 +174,17 @@ declare class ClawOpsAgent {
|
|
|
164
174
|
private _recording;
|
|
165
175
|
private _recordingPath;
|
|
166
176
|
private _activeSessions;
|
|
177
|
+
/** Set once the server hands this number to another process (rolling deploy takeover). */
|
|
178
|
+
private _takenOver;
|
|
179
|
+
/**
|
|
180
|
+
* Set once we deliberately give up the control connection (`drain()`/`disconnect()`).
|
|
181
|
+
* Distinct from `_controlWs === null`, which also covers "never connected": only after a
|
|
182
|
+
* hand-back is it certain that no server terminal frame can still arrive.
|
|
183
|
+
*/
|
|
184
|
+
private _controlGivenUp;
|
|
185
|
+
/** serve()'s stop hook, so takeover can end the block the same way a signal does. */
|
|
186
|
+
private _stopServe;
|
|
187
|
+
private _onTakenOver?;
|
|
167
188
|
/** 미디어 정리를 마친 통화가 서버 종료 프레임을 기다리는 자리. callId → resolve. */
|
|
168
189
|
private _terminalWaiters;
|
|
169
190
|
private _builtinTools;
|
|
@@ -206,10 +227,64 @@ declare class ClawOpsAgent {
|
|
|
206
227
|
/** Connect to the ClawOps platform and start listening for calls. */
|
|
207
228
|
connect(): Promise<void>;
|
|
208
229
|
/**
|
|
209
|
-
* Connect and block until
|
|
210
|
-
*
|
|
230
|
+
* Connect and block until it is time to stop.
|
|
231
|
+
*
|
|
232
|
+
* Returns on SIGINT/SIGTERM, or when another process takes over this number — in every case
|
|
233
|
+
* after `drain()` has let in-flight calls finish. A second signal skips the wait and cuts them.
|
|
234
|
+
*
|
|
235
|
+
* Because it returns on takeover, a rolling deploy needs no shutdown wiring: bring the new
|
|
236
|
+
* instance up, and the old one hands over the number, finishes the calls it still has, and
|
|
237
|
+
* exits on its own. Give the platform a grace period longer than the drain timeout
|
|
238
|
+
* (k8s `terminationGracePeriodSeconds`, ECS `stopTimeout`) so it does not SIGKILL mid-drain.
|
|
239
|
+
*
|
|
240
|
+
* @param options.drainTimeoutMs Passed through to `drain()`.
|
|
241
|
+
*/
|
|
242
|
+
serve(options?: {
|
|
243
|
+
drainTimeoutMs?: number;
|
|
244
|
+
}): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* The server handed this number's control connection to another process.
|
|
247
|
+
*
|
|
248
|
+
* Nothing here is an error: it is the normal middle of a rolling deploy, seen from the
|
|
249
|
+
* instance being replaced. New calls already go to the new process, so all that is left is
|
|
250
|
+
* to finish the calls we still hold and get out of the way. `serve()` does that by returning;
|
|
251
|
+
* callers who wired `connect()` themselves get `onTakenOver` and should call `drain()`.
|
|
211
252
|
*/
|
|
212
|
-
|
|
253
|
+
private _handleTakenOver;
|
|
254
|
+
/** Whether another process has taken over this number's control connection. */
|
|
255
|
+
get takenOver(): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Stop accepting new calls, let the ones already in progress finish, then disconnect.
|
|
258
|
+
*
|
|
259
|
+
* This is what a rolling deploy needs. `disconnect()` cuts live calls mid-sentence, which is
|
|
260
|
+
* correct when you mean "stop now" and wrong when you mean "hand over". The two are separated
|
|
261
|
+
* because only the caller knows which one a SIGTERM meant.
|
|
262
|
+
*
|
|
263
|
+
* It works because control and media are different connections. Closing the control WebSocket
|
|
264
|
+
* only gives up this number's delivery slot — the server stops sending us `call.incoming` and
|
|
265
|
+
* routes new calls to whichever process holds the slot next. Calls already up keep streaming
|
|
266
|
+
* over their own per-call media connections, which nothing here touches, and each one tears
|
|
267
|
+
* itself down normally when the caller hangs up.
|
|
268
|
+
*
|
|
269
|
+
* Deploy shape this is built for: start the new instance, let it take the slot (the server
|
|
270
|
+
* hands it over and tells us not to reconnect), then drain the old one. New calls go to the
|
|
271
|
+
* new instance from the moment it connects; in-flight calls end on the old one. No gap.
|
|
272
|
+
*
|
|
273
|
+
* One thing is given up: `endedDuration` on `call_end`. That figure rides the control
|
|
274
|
+
* connection we just closed, so calls finishing during a drain report a null duration.
|
|
275
|
+
*
|
|
276
|
+
* @param options.timeoutMs How long to wait for in-flight calls. Default 120s. Calls still
|
|
277
|
+
* running when it expires are ended the way `disconnect()` ends them. Keep the platform's
|
|
278
|
+
* own grace period longer than this (k8s `terminationGracePeriodSeconds`, ECS
|
|
279
|
+
* `stopTimeout`), or it will SIGKILL the process mid-drain and undo the point of draining.
|
|
280
|
+
* @returns How many calls ended on their own, and how many had to be cut short.
|
|
281
|
+
*/
|
|
282
|
+
drain(options?: {
|
|
283
|
+
timeoutMs?: number;
|
|
284
|
+
}): Promise<{
|
|
285
|
+
completed: number;
|
|
286
|
+
forced: number;
|
|
287
|
+
}>;
|
|
213
288
|
/** Disconnect from the platform. */
|
|
214
289
|
disconnect(): Promise<void>;
|
|
215
290
|
/**
|
|
@@ -288,6 +363,15 @@ interface ControlWsOptions {
|
|
|
288
363
|
accountId: string;
|
|
289
364
|
/** Phone number to register on. */
|
|
290
365
|
number?: string;
|
|
366
|
+
/**
|
|
367
|
+
* Called when the server closes the connection with a code that means "do not reconnect"
|
|
368
|
+
* (the number was handed to another process, or is no longer owned by this account).
|
|
369
|
+
* Reconnection has already been abandoned by the time this fires.
|
|
370
|
+
*/
|
|
371
|
+
onTerminalClose?: (info: {
|
|
372
|
+
code: number;
|
|
373
|
+
reason: string;
|
|
374
|
+
}) => void;
|
|
291
375
|
}
|
|
292
376
|
/**
|
|
293
377
|
* Control event is a flat JSON object with an 'event' field.
|
package/dist/agent/index.d.ts
CHANGED
|
@@ -127,6 +127,16 @@ interface ClawOpsAgentOptions {
|
|
|
127
127
|
logger?: Logger;
|
|
128
128
|
/** Tool 실행 관련 설정. */
|
|
129
129
|
toolConfig?: ToolConfig;
|
|
130
|
+
/**
|
|
131
|
+
* Called when another process takes over this number's control connection — the normal
|
|
132
|
+
* middle of a rolling deploy, seen from the instance being replaced. New calls already go
|
|
133
|
+
* elsewhere; finish the calls still in flight and exit. `serve()` handles this for you;
|
|
134
|
+
* wire this only if you drive `connect()` yourself, and call `drain()` from it.
|
|
135
|
+
*/
|
|
136
|
+
onTakenOver?: (info: {
|
|
137
|
+
code: number;
|
|
138
|
+
reason: string;
|
|
139
|
+
}) => void;
|
|
130
140
|
/**
|
|
131
141
|
* Gain applied to inbound audio (caller → AI). 1.0 = pass-through (default), 0 = mute, 2.0 = 2x amplify.
|
|
132
142
|
* AI/STT receive the gained audio, and recording captures it post-gain.
|
|
@@ -164,6 +174,17 @@ declare class ClawOpsAgent {
|
|
|
164
174
|
private _recording;
|
|
165
175
|
private _recordingPath;
|
|
166
176
|
private _activeSessions;
|
|
177
|
+
/** Set once the server hands this number to another process (rolling deploy takeover). */
|
|
178
|
+
private _takenOver;
|
|
179
|
+
/**
|
|
180
|
+
* Set once we deliberately give up the control connection (`drain()`/`disconnect()`).
|
|
181
|
+
* Distinct from `_controlWs === null`, which also covers "never connected": only after a
|
|
182
|
+
* hand-back is it certain that no server terminal frame can still arrive.
|
|
183
|
+
*/
|
|
184
|
+
private _controlGivenUp;
|
|
185
|
+
/** serve()'s stop hook, so takeover can end the block the same way a signal does. */
|
|
186
|
+
private _stopServe;
|
|
187
|
+
private _onTakenOver?;
|
|
167
188
|
/** 미디어 정리를 마친 통화가 서버 종료 프레임을 기다리는 자리. callId → resolve. */
|
|
168
189
|
private _terminalWaiters;
|
|
169
190
|
private _builtinTools;
|
|
@@ -206,10 +227,64 @@ declare class ClawOpsAgent {
|
|
|
206
227
|
/** Connect to the ClawOps platform and start listening for calls. */
|
|
207
228
|
connect(): Promise<void>;
|
|
208
229
|
/**
|
|
209
|
-
* Connect and block until
|
|
210
|
-
*
|
|
230
|
+
* Connect and block until it is time to stop.
|
|
231
|
+
*
|
|
232
|
+
* Returns on SIGINT/SIGTERM, or when another process takes over this number — in every case
|
|
233
|
+
* after `drain()` has let in-flight calls finish. A second signal skips the wait and cuts them.
|
|
234
|
+
*
|
|
235
|
+
* Because it returns on takeover, a rolling deploy needs no shutdown wiring: bring the new
|
|
236
|
+
* instance up, and the old one hands over the number, finishes the calls it still has, and
|
|
237
|
+
* exits on its own. Give the platform a grace period longer than the drain timeout
|
|
238
|
+
* (k8s `terminationGracePeriodSeconds`, ECS `stopTimeout`) so it does not SIGKILL mid-drain.
|
|
239
|
+
*
|
|
240
|
+
* @param options.drainTimeoutMs Passed through to `drain()`.
|
|
241
|
+
*/
|
|
242
|
+
serve(options?: {
|
|
243
|
+
drainTimeoutMs?: number;
|
|
244
|
+
}): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* The server handed this number's control connection to another process.
|
|
247
|
+
*
|
|
248
|
+
* Nothing here is an error: it is the normal middle of a rolling deploy, seen from the
|
|
249
|
+
* instance being replaced. New calls already go to the new process, so all that is left is
|
|
250
|
+
* to finish the calls we still hold and get out of the way. `serve()` does that by returning;
|
|
251
|
+
* callers who wired `connect()` themselves get `onTakenOver` and should call `drain()`.
|
|
211
252
|
*/
|
|
212
|
-
|
|
253
|
+
private _handleTakenOver;
|
|
254
|
+
/** Whether another process has taken over this number's control connection. */
|
|
255
|
+
get takenOver(): boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Stop accepting new calls, let the ones already in progress finish, then disconnect.
|
|
258
|
+
*
|
|
259
|
+
* This is what a rolling deploy needs. `disconnect()` cuts live calls mid-sentence, which is
|
|
260
|
+
* correct when you mean "stop now" and wrong when you mean "hand over". The two are separated
|
|
261
|
+
* because only the caller knows which one a SIGTERM meant.
|
|
262
|
+
*
|
|
263
|
+
* It works because control and media are different connections. Closing the control WebSocket
|
|
264
|
+
* only gives up this number's delivery slot — the server stops sending us `call.incoming` and
|
|
265
|
+
* routes new calls to whichever process holds the slot next. Calls already up keep streaming
|
|
266
|
+
* over their own per-call media connections, which nothing here touches, and each one tears
|
|
267
|
+
* itself down normally when the caller hangs up.
|
|
268
|
+
*
|
|
269
|
+
* Deploy shape this is built for: start the new instance, let it take the slot (the server
|
|
270
|
+
* hands it over and tells us not to reconnect), then drain the old one. New calls go to the
|
|
271
|
+
* new instance from the moment it connects; in-flight calls end on the old one. No gap.
|
|
272
|
+
*
|
|
273
|
+
* One thing is given up: `endedDuration` on `call_end`. That figure rides the control
|
|
274
|
+
* connection we just closed, so calls finishing during a drain report a null duration.
|
|
275
|
+
*
|
|
276
|
+
* @param options.timeoutMs How long to wait for in-flight calls. Default 120s. Calls still
|
|
277
|
+
* running when it expires are ended the way `disconnect()` ends them. Keep the platform's
|
|
278
|
+
* own grace period longer than this (k8s `terminationGracePeriodSeconds`, ECS
|
|
279
|
+
* `stopTimeout`), or it will SIGKILL the process mid-drain and undo the point of draining.
|
|
280
|
+
* @returns How many calls ended on their own, and how many had to be cut short.
|
|
281
|
+
*/
|
|
282
|
+
drain(options?: {
|
|
283
|
+
timeoutMs?: number;
|
|
284
|
+
}): Promise<{
|
|
285
|
+
completed: number;
|
|
286
|
+
forced: number;
|
|
287
|
+
}>;
|
|
213
288
|
/** Disconnect from the platform. */
|
|
214
289
|
disconnect(): Promise<void>;
|
|
215
290
|
/**
|
|
@@ -288,6 +363,15 @@ interface ControlWsOptions {
|
|
|
288
363
|
accountId: string;
|
|
289
364
|
/** Phone number to register on. */
|
|
290
365
|
number?: string;
|
|
366
|
+
/**
|
|
367
|
+
* Called when the server closes the connection with a code that means "do not reconnect"
|
|
368
|
+
* (the number was handed to another process, or is no longer owned by this account).
|
|
369
|
+
* Reconnection has already been abandoned by the time this fires.
|
|
370
|
+
*/
|
|
371
|
+
onTerminalClose?: (info: {
|
|
372
|
+
code: number;
|
|
373
|
+
reason: string;
|
|
374
|
+
}) => void;
|
|
291
375
|
}
|
|
292
376
|
/**
|
|
293
377
|
* Control event is a flat JSON object with an 'event' field.
|
package/dist/agent/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_BASE_URL } from '../chunk-35XJNMFU.js';
|
|
2
2
|
import { AgentError, AgentConnectionError } from '../chunk-SXXOAPMG.js';
|
|
3
|
-
import { NOOP_LOGGER, resolveBuiltinTools, createAgentLogger, createPipelineLogger, getSdkInfo, CallSession, BufferingCall, attachBuffered, ulawToPcm16, resamplePcm16, getBuiltinToolSchemas, BUILTIN_TOOL_NAMES, executeBuiltinTool, CALL_NOT_READY_RESULT, pcm16ToUlaw, applyUlawGain } from '../chunk-
|
|
4
|
-
export { BUILTIN_TOOL_NAMES, BuiltinTool, CallSession, DECODE_TABLE, DtmfCollectorBusyError, createAgentLogger, createPipelineLogger, executeBuiltinTool, getBuiltinToolSchemas, isBuiltinTool, pcm16ToUlaw, resamplePcm16, ulawToPcm16 } from '../chunk-
|
|
5
|
-
import '../chunk-
|
|
3
|
+
import { NOOP_LOGGER, resolveBuiltinTools, createAgentLogger, createPipelineLogger, getSdkInfo, CallSession, BufferingCall, attachBuffered, ulawToPcm16, resamplePcm16, getBuiltinToolSchemas, BUILTIN_TOOL_NAMES, executeBuiltinTool, CALL_NOT_READY_RESULT, pcm16ToUlaw, applyUlawGain } from '../chunk-HJLF7U7U.js';
|
|
4
|
+
export { BUILTIN_TOOL_NAMES, BuiltinTool, CallSession, DECODE_TABLE, DtmfCollectorBusyError, createAgentLogger, createPipelineLogger, executeBuiltinTool, getBuiltinToolSchemas, isBuiltinTool, pcm16ToUlaw, resamplePcm16, ulawToPcm16 } from '../chunk-HJLF7U7U.js';
|
|
5
|
+
import '../chunk-XENRMYVS.js';
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
|
|
@@ -10,6 +10,9 @@ import * as path from 'path';
|
|
|
10
10
|
var INITIAL_RECONNECT_DELAY = 1e3;
|
|
11
11
|
var MAX_RECONNECT_DELAY = 3e4;
|
|
12
12
|
var PING_TIMEOUT = 6e4;
|
|
13
|
+
var CLOSE_REPLACED = 4409;
|
|
14
|
+
var CLOSE_OWNERSHIP_LOST = 4403;
|
|
15
|
+
var NON_RETRYABLE_CLOSE_CODES = /* @__PURE__ */ new Set([CLOSE_REPLACED, CLOSE_OWNERSHIP_LOST]);
|
|
13
16
|
function buildControlWsUrl(options) {
|
|
14
17
|
const scheme = options.baseUrl.startsWith("https") ? "wss" : "ws";
|
|
15
18
|
const host = options.baseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
@@ -131,11 +134,21 @@ var ControlWebSocket = class {
|
|
|
131
134
|
this._log.warn("Control WS parse error");
|
|
132
135
|
}
|
|
133
136
|
});
|
|
134
|
-
ws.on("close", () => {
|
|
137
|
+
ws.on("close", (code, reason) => {
|
|
135
138
|
this._clearPingTimer();
|
|
136
|
-
if (
|
|
137
|
-
|
|
139
|
+
if (this._closed) return;
|
|
140
|
+
if (NON_RETRYABLE_CLOSE_CODES.has(code)) {
|
|
141
|
+
const text = reason?.toString() || "";
|
|
142
|
+
this._closed = true;
|
|
143
|
+
this._log.info(
|
|
144
|
+
"Control WS closed by server (%d %s) \u2014 not reconnecting: this number is now served elsewhere",
|
|
145
|
+
code,
|
|
146
|
+
text
|
|
147
|
+
);
|
|
148
|
+
this._options.onTerminalClose?.({ code, reason: text });
|
|
149
|
+
return;
|
|
138
150
|
}
|
|
151
|
+
this._scheduleReconnect();
|
|
139
152
|
});
|
|
140
153
|
ws.on("error", (err) => {
|
|
141
154
|
this._log.warn("Control WS error: %s", err.message);
|
|
@@ -1077,6 +1090,8 @@ var ATTR_AGENT_ID = "clawops.agent.id";
|
|
|
1077
1090
|
|
|
1078
1091
|
// src/agent/agent.ts
|
|
1079
1092
|
var TERMINAL_FRAME_GRACE_MS = 2e3;
|
|
1093
|
+
var DEFAULT_DRAIN_TIMEOUT_MS = 12e4;
|
|
1094
|
+
var DRAIN_POLL_INTERVAL_MS = 200;
|
|
1080
1095
|
var ClawOpsAgent = class _ClawOpsAgent {
|
|
1081
1096
|
_apiKey;
|
|
1082
1097
|
_accountId;
|
|
@@ -1090,6 +1105,17 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1090
1105
|
_recording;
|
|
1091
1106
|
_recordingPath;
|
|
1092
1107
|
_activeSessions = /* @__PURE__ */ new Map();
|
|
1108
|
+
/** Set once the server hands this number to another process (rolling deploy takeover). */
|
|
1109
|
+
_takenOver = false;
|
|
1110
|
+
/**
|
|
1111
|
+
* Set once we deliberately give up the control connection (`drain()`/`disconnect()`).
|
|
1112
|
+
* Distinct from `_controlWs === null`, which also covers "never connected": only after a
|
|
1113
|
+
* hand-back is it certain that no server terminal frame can still arrive.
|
|
1114
|
+
*/
|
|
1115
|
+
_controlGivenUp = false;
|
|
1116
|
+
/** serve()'s stop hook, so takeover can end the block the same way a signal does. */
|
|
1117
|
+
_stopServe = null;
|
|
1118
|
+
_onTakenOver;
|
|
1093
1119
|
/** 미디어 정리를 마친 통화가 서버 종료 프레임을 기다리는 자리. callId → resolve. */
|
|
1094
1120
|
_terminalWaiters = /* @__PURE__ */ new Map();
|
|
1095
1121
|
_builtinTools;
|
|
@@ -1126,6 +1152,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1126
1152
|
this._txGain = _ClawOpsAgent._validateGain("txGain", options.txGain ?? 1);
|
|
1127
1153
|
this._prewarmEnabled = options.prewarmEnabled ?? true;
|
|
1128
1154
|
this._machineDetection = options.machineDetection;
|
|
1155
|
+
this._onTakenOver = options.onTakenOver;
|
|
1129
1156
|
if (options.tracing) {
|
|
1130
1157
|
setTracingConfig(options.tracing);
|
|
1131
1158
|
}
|
|
@@ -1187,6 +1214,11 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1187
1214
|
/** Connect to the ClawOps platform and start listening for calls. */
|
|
1188
1215
|
async connect() {
|
|
1189
1216
|
if (this._controlWs) return;
|
|
1217
|
+
if (this._takenOver) {
|
|
1218
|
+
throw new AgentError(
|
|
1219
|
+
`${this._fromNumber} is now served by another process \u2014 reconnecting would evict it. Start a new agent process instead of reconnecting this one.`
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1190
1222
|
if (!this._apiKey) {
|
|
1191
1223
|
throw new AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
|
|
1192
1224
|
}
|
|
@@ -1195,11 +1227,13 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1195
1227
|
"Account ID is required. Set CLAWOPS_ACCOUNT_ID or pass accountId option."
|
|
1196
1228
|
);
|
|
1197
1229
|
}
|
|
1230
|
+
this._controlGivenUp = false;
|
|
1198
1231
|
this._controlWs = new ControlWebSocket({
|
|
1199
1232
|
baseUrl: this._baseUrl,
|
|
1200
1233
|
apiKey: this._apiKey,
|
|
1201
1234
|
accountId: this._accountId,
|
|
1202
|
-
number: this._fromNumber
|
|
1235
|
+
number: this._fromNumber,
|
|
1236
|
+
onTerminalClose: (info) => this._handleTakenOver(info)
|
|
1203
1237
|
});
|
|
1204
1238
|
this._controlWs.setLogger(this._log);
|
|
1205
1239
|
this._controlWs.on("call.incoming", (event) => this._handleIncoming(event));
|
|
@@ -1222,21 +1256,140 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1222
1256
|
this._log.info("ClawOpsAgent connected on %s", this._fromNumber);
|
|
1223
1257
|
}
|
|
1224
1258
|
/**
|
|
1225
|
-
* Connect and block until
|
|
1226
|
-
*
|
|
1259
|
+
* Connect and block until it is time to stop.
|
|
1260
|
+
*
|
|
1261
|
+
* Returns on SIGINT/SIGTERM, or when another process takes over this number — in every case
|
|
1262
|
+
* after `drain()` has let in-flight calls finish. A second signal skips the wait and cuts them.
|
|
1263
|
+
*
|
|
1264
|
+
* Because it returns on takeover, a rolling deploy needs no shutdown wiring: bring the new
|
|
1265
|
+
* instance up, and the old one hands over the number, finishes the calls it still has, and
|
|
1266
|
+
* exits on its own. Give the platform a grace period longer than the drain timeout
|
|
1267
|
+
* (k8s `terminationGracePeriodSeconds`, ECS `stopTimeout`) so it does not SIGKILL mid-drain.
|
|
1268
|
+
*
|
|
1269
|
+
* @param options.drainTimeoutMs Passed through to `drain()`.
|
|
1227
1270
|
*/
|
|
1228
|
-
async serve() {
|
|
1271
|
+
async serve(options) {
|
|
1229
1272
|
await this.connect();
|
|
1230
1273
|
return new Promise((resolve) => {
|
|
1231
|
-
|
|
1232
|
-
|
|
1274
|
+
let stopping = false;
|
|
1275
|
+
let signals = 0;
|
|
1276
|
+
const finish = () => {
|
|
1277
|
+
process.off("SIGINT", onSigint);
|
|
1278
|
+
process.off("SIGTERM", onSigterm);
|
|
1279
|
+
this._stopServe = null;
|
|
1280
|
+
resolve();
|
|
1281
|
+
};
|
|
1282
|
+
const stop = (why) => {
|
|
1283
|
+
if (stopping) return;
|
|
1284
|
+
stopping = true;
|
|
1285
|
+
this._log.info("Stopping (%s) \u2014 draining in-flight calls", why);
|
|
1286
|
+
this.drain({ timeoutMs: options?.drainTimeoutMs }).then(finish).catch((err) => {
|
|
1287
|
+
this._log.error({ err }, "Drain failed");
|
|
1288
|
+
finish();
|
|
1289
|
+
});
|
|
1290
|
+
};
|
|
1291
|
+
const onSignal = (why) => {
|
|
1292
|
+
signals += 1;
|
|
1293
|
+
if (signals >= 2) {
|
|
1294
|
+
this._log.warn("Second stop signal (%s) \u2014 ending calls immediately", why);
|
|
1295
|
+
void this.disconnect().finally(finish);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (stopping) {
|
|
1299
|
+
this._log.info(
|
|
1300
|
+
"%s arrived while draining \u2014 still waiting for in-flight calls (signal again to cut)",
|
|
1301
|
+
why
|
|
1302
|
+
);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
stop(why);
|
|
1233
1306
|
};
|
|
1234
|
-
|
|
1235
|
-
|
|
1307
|
+
const onSigint = () => onSignal("SIGINT");
|
|
1308
|
+
const onSigterm = () => onSignal("SIGTERM");
|
|
1309
|
+
this._stopServe = stop;
|
|
1310
|
+
process.on("SIGINT", onSigint);
|
|
1311
|
+
process.on("SIGTERM", onSigterm);
|
|
1236
1312
|
});
|
|
1237
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* The server handed this number's control connection to another process.
|
|
1316
|
+
*
|
|
1317
|
+
* Nothing here is an error: it is the normal middle of a rolling deploy, seen from the
|
|
1318
|
+
* instance being replaced. New calls already go to the new process, so all that is left is
|
|
1319
|
+
* to finish the calls we still hold and get out of the way. `serve()` does that by returning;
|
|
1320
|
+
* callers who wired `connect()` themselves get `onTakenOver` and should call `drain()`.
|
|
1321
|
+
*/
|
|
1322
|
+
_handleTakenOver(info) {
|
|
1323
|
+
this._takenOver = true;
|
|
1324
|
+
this._log.info(
|
|
1325
|
+
"Another process now serves %s (close %d) \u2014 handing over",
|
|
1326
|
+
this._fromNumber,
|
|
1327
|
+
info.code
|
|
1328
|
+
);
|
|
1329
|
+
this._onTakenOver?.(info);
|
|
1330
|
+
this._stopServe?.("taken over");
|
|
1331
|
+
}
|
|
1332
|
+
/** Whether another process has taken over this number's control connection. */
|
|
1333
|
+
get takenOver() {
|
|
1334
|
+
return this._takenOver;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Stop accepting new calls, let the ones already in progress finish, then disconnect.
|
|
1338
|
+
*
|
|
1339
|
+
* This is what a rolling deploy needs. `disconnect()` cuts live calls mid-sentence, which is
|
|
1340
|
+
* correct when you mean "stop now" and wrong when you mean "hand over". The two are separated
|
|
1341
|
+
* because only the caller knows which one a SIGTERM meant.
|
|
1342
|
+
*
|
|
1343
|
+
* It works because control and media are different connections. Closing the control WebSocket
|
|
1344
|
+
* only gives up this number's delivery slot — the server stops sending us `call.incoming` and
|
|
1345
|
+
* routes new calls to whichever process holds the slot next. Calls already up keep streaming
|
|
1346
|
+
* over their own per-call media connections, which nothing here touches, and each one tears
|
|
1347
|
+
* itself down normally when the caller hangs up.
|
|
1348
|
+
*
|
|
1349
|
+
* Deploy shape this is built for: start the new instance, let it take the slot (the server
|
|
1350
|
+
* hands it over and tells us not to reconnect), then drain the old one. New calls go to the
|
|
1351
|
+
* new instance from the moment it connects; in-flight calls end on the old one. No gap.
|
|
1352
|
+
*
|
|
1353
|
+
* One thing is given up: `endedDuration` on `call_end`. That figure rides the control
|
|
1354
|
+
* connection we just closed, so calls finishing during a drain report a null duration.
|
|
1355
|
+
*
|
|
1356
|
+
* @param options.timeoutMs How long to wait for in-flight calls. Default 120s. Calls still
|
|
1357
|
+
* running when it expires are ended the way `disconnect()` ends them. Keep the platform's
|
|
1358
|
+
* own grace period longer than this (k8s `terminationGracePeriodSeconds`, ECS
|
|
1359
|
+
* `stopTimeout`), or it will SIGKILL the process mid-drain and undo the point of draining.
|
|
1360
|
+
* @returns How many calls ended on their own, and how many had to be cut short.
|
|
1361
|
+
*/
|
|
1362
|
+
async drain(options) {
|
|
1363
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
1364
|
+
this._controlGivenUp = true;
|
|
1365
|
+
if (this._controlWs) {
|
|
1366
|
+
this._controlWs.close();
|
|
1367
|
+
this._controlWs = null;
|
|
1368
|
+
}
|
|
1369
|
+
for (const wake of [...this._terminalWaiters.values()]) wake();
|
|
1370
|
+
const inFlight = this._activeSessions.size;
|
|
1371
|
+
if (inFlight === 0) {
|
|
1372
|
+
this._log.info("Drain: no calls in progress");
|
|
1373
|
+
await this.disconnect();
|
|
1374
|
+
return { completed: 0, forced: 0 };
|
|
1375
|
+
}
|
|
1376
|
+
this._log.info("Drain: waiting for %d call(s) to finish (timeout %dms)", inFlight, timeoutMs);
|
|
1377
|
+
const deadline = Date.now() + timeoutMs;
|
|
1378
|
+
while (this._activeSessions.size > 0 && Date.now() < deadline) {
|
|
1379
|
+
await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_INTERVAL_MS));
|
|
1380
|
+
}
|
|
1381
|
+
const forced = this._activeSessions.size;
|
|
1382
|
+
if (forced > 0) {
|
|
1383
|
+
this._log.warn("Drain timed out \u2014 cutting %d call(s) still in progress", forced);
|
|
1384
|
+
} else {
|
|
1385
|
+
this._log.info("Drain complete: all %d call(s) finished", inFlight);
|
|
1386
|
+
}
|
|
1387
|
+
await this.disconnect();
|
|
1388
|
+
return { completed: inFlight - forced, forced };
|
|
1389
|
+
}
|
|
1238
1390
|
/** Disconnect from the platform. */
|
|
1239
1391
|
async disconnect() {
|
|
1392
|
+
this._controlGivenUp = true;
|
|
1240
1393
|
if (this._controlWs) {
|
|
1241
1394
|
this._controlWs.close();
|
|
1242
1395
|
this._controlWs = null;
|
|
@@ -1341,6 +1494,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1341
1494
|
*/
|
|
1342
1495
|
async _awaitServerTerminal(session) {
|
|
1343
1496
|
if (session.endedDuration !== null) return;
|
|
1497
|
+
if (this._controlGivenUp) return;
|
|
1344
1498
|
await new Promise((resolve) => {
|
|
1345
1499
|
const done = () => {
|
|
1346
1500
|
clearTimeout(timer);
|
|
@@ -1633,7 +1787,15 @@ var ClawOpsAgent = class _ClawOpsAgent {
|
|
|
1633
1787
|
},
|
|
1634
1788
|
() => mediaWs.isConnected
|
|
1635
1789
|
);
|
|
1636
|
-
session._transferFn = (params) =>
|
|
1790
|
+
session._transferFn = (params) => {
|
|
1791
|
+
const controlWs = this._controlWs;
|
|
1792
|
+
if (!controlWs) {
|
|
1793
|
+
throw new AgentError(
|
|
1794
|
+
"transfer unavailable: the control connection is closed (draining, or this number was taken over)"
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
return controlWs.requestTransfer(session.callId, params);
|
|
1798
|
+
};
|
|
1637
1799
|
session._sendMark = (name) => mediaWs.sendMark(name);
|
|
1638
1800
|
session._waitForMark = (name, timeoutMs) => mediaWs.waitForMark(name, timeoutMs);
|
|
1639
1801
|
session._flushTransport = () => mediaWs.flush();
|