@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.
@@ -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 disconnected.
210
- * Convenience method for simple agent scripts.
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
- serve(): Promise<void>;
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.
@@ -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 disconnected.
210
- * Convenience method for simple agent scripts.
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
- serve(): Promise<void>;
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.
@@ -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-AYSW63CJ.js';
4
- export { BUILTIN_TOOL_NAMES, BuiltinTool, CallSession, DECODE_TABLE, DtmfCollectorBusyError, createAgentLogger, createPipelineLogger, executeBuiltinTool, getBuiltinToolSchemas, isBuiltinTool, pcm16ToUlaw, resamplePcm16, ulawToPcm16 } from '../chunk-AYSW63CJ.js';
5
- import '../chunk-R6UEIVYE.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-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 (!this._closed) {
137
- this._scheduleReconnect();
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 disconnected.
1226
- * Convenience method for simple agent scripts.
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
- const shutdown = () => {
1232
- this.disconnect().then(resolve).catch(() => resolve());
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
- process.on("SIGINT", shutdown);
1235
- process.on("SIGTERM", shutdown);
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) => this._controlWs.requestTransfer(session.callId, 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();