@vellumai/assistant 0.3.13 → 0.3.15

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.
Files changed (57) hide show
  1. package/ARCHITECTURE.md +17 -3
  2. package/Dockerfile +1 -1
  3. package/README.md +2 -0
  4. package/docs/architecture/scheduling.md +81 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
  7. package/src/__tests__/channel-policy.test.ts +19 -0
  8. package/src/__tests__/guardian-control-plane-policy.test.ts +582 -0
  9. package/src/__tests__/guardian-outbound-http.test.ts +8 -8
  10. package/src/__tests__/intent-routing.test.ts +22 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  12. package/src/__tests__/notification-routing-intent.test.ts +185 -0
  13. package/src/__tests__/recording-handler.test.ts +191 -31
  14. package/src/__tests__/recording-intent-fallback.test.ts +180 -0
  15. package/src/__tests__/recording-intent-handler.test.ts +597 -74
  16. package/src/__tests__/recording-intent.test.ts +738 -342
  17. package/src/__tests__/recording-state-machine.test.ts +1109 -0
  18. package/src/__tests__/reminder-store.test.ts +20 -18
  19. package/src/__tests__/reminder.test.ts +2 -1
  20. package/src/channels/config.ts +1 -1
  21. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
  22. package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
  23. package/src/config/system-prompt.ts +5 -0
  24. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  25. package/src/daemon/handlers/config-channels.ts +6 -6
  26. package/src/daemon/handlers/index.ts +1 -1
  27. package/src/daemon/handlers/misc.ts +258 -102
  28. package/src/daemon/handlers/recording.ts +417 -5
  29. package/src/daemon/handlers/sessions.ts +142 -68
  30. package/src/daemon/ipc-contract/computer-use.ts +23 -3
  31. package/src/daemon/ipc-contract/messages.ts +3 -1
  32. package/src/daemon/ipc-contract/shared.ts +6 -0
  33. package/src/daemon/ipc-contract-inventory.json +2 -0
  34. package/src/daemon/lifecycle.ts +2 -0
  35. package/src/daemon/recording-executor.ts +180 -0
  36. package/src/daemon/recording-intent-fallback.ts +132 -0
  37. package/src/daemon/recording-intent.ts +306 -15
  38. package/src/daemon/session-tool-setup.ts +4 -0
  39. package/src/memory/conversation-attention-store.ts +5 -5
  40. package/src/notifications/README.md +69 -1
  41. package/src/notifications/adapters/sms.ts +80 -0
  42. package/src/notifications/broadcaster.ts +1 -0
  43. package/src/notifications/copy-composer.ts +3 -3
  44. package/src/notifications/decision-engine.ts +70 -1
  45. package/src/notifications/decisions-store.ts +24 -0
  46. package/src/notifications/destination-resolver.ts +2 -1
  47. package/src/notifications/emit-signal.ts +35 -3
  48. package/src/notifications/signal.ts +6 -0
  49. package/src/notifications/types.ts +3 -0
  50. package/src/runtime/guardian-outbound-actions.ts +9 -9
  51. package/src/runtime/http-server.ts +7 -7
  52. package/src/runtime/routes/conversation-attention-routes.ts +3 -3
  53. package/src/runtime/routes/integration-routes.ts +5 -5
  54. package/src/schedule/scheduler.ts +15 -3
  55. package/src/tools/executor.ts +29 -0
  56. package/src/tools/guardian-control-plane-policy.ts +141 -0
  57. package/src/tools/types.ts +2 -0
@@ -37,18 +37,45 @@ const recordingOwnerByConversation = new Map<string, string>();
37
37
  /** Pending stop-acknowledgement timeouts keyed by recordingId. */
38
38
  const pendingStopTimeouts = new Map<string, NodeJS.Timeout>();
39
39
 
40
+ /** Current restart operation token. When non-null, the recording system is
41
+ * mid-restart and any async completions (started/failed) from a previous
42
+ * cycle with a mismatched token are rejected. */
43
+ let activeRestartToken: string | null = null;
44
+
45
+ /** Tracks which conversationId has a pending restart so "no active recording"
46
+ * is only returned when the state is truly idle (not mid-restart). */
47
+ const pendingRestartByConversation = new Map<string, string>();
48
+
49
+ /** Deferred restart parameters stored when a restart is initiated. The actual
50
+ * recording_start is sent only after the client acknowledges the stop (via a
51
+ * 'stopped' status callback), preventing the race where the macOS client's
52
+ * async stop hasn't completed when the start arrives. */
53
+ interface DeferredRestartParams {
54
+ conversationId: string;
55
+ operationToken: string;
56
+ }
57
+
58
+ /** Maps conversationId -> deferred restart parameters. Populated by
59
+ * handleRecordingRestart, consumed by the 'stopped' branch of
60
+ * handleRecordingStatus. */
61
+ const deferredRestartByConversation = new Map<string, DeferredRestartParams>();
62
+
40
63
  // ─── Start ───────────────────────────────────────────────────────────────────
41
64
 
42
65
  /**
43
66
  * Initiate a standalone recording for a conversation.
44
67
  * Generates a unique recording ID, stores deterministic mappings, and sends
45
68
  * a `recording_start` message to the client.
69
+ *
70
+ * When `operationToken` is provided (restart flow), it is threaded through
71
+ * to the client so that status callbacks can be validated against the token.
46
72
  */
47
73
  export function handleRecordingStart(
48
74
  conversationId: string,
49
75
  options: RecordingOptions | undefined,
50
76
  socket: net.Socket,
51
77
  ctx: HandlerContext,
78
+ operationToken?: string,
52
79
  ): string | null {
53
80
  const existingRecordingId = recordingOwnerByConversation.get(conversationId);
54
81
  if (existingRecordingId) {
@@ -73,9 +100,10 @@ export function handleRecordingStart(
73
100
  recordingId,
74
101
  attachToConversationId: conversationId,
75
102
  options,
103
+ ...(operationToken ? { operationToken } : {}),
76
104
  });
77
105
 
78
- log.info({ recordingId, conversationId }, 'Standalone recording started');
106
+ log.info({ recordingId, conversationId, operationToken }, 'Standalone recording started');
79
107
  return recordingId;
80
108
  }
81
109
 
@@ -136,6 +164,16 @@ export function handleRecordingStop(
136
164
  pendingStopTimeouts.delete(recordingId);
137
165
  log.warn({ recordingId, conversationId: ownerConversationId, timeoutMs: STOP_ACK_TIMEOUT_MS }, 'Stop-acknowledgement timeout fired — cleaning up stale recording state');
138
166
  cleanupMaps(recordingId, ownerConversationId);
167
+
168
+ // Clean up any deferred restart that was waiting for this stop-ack
169
+ if (deferredRestartByConversation.has(ownerConversationId)) {
170
+ deferredRestartByConversation.delete(ownerConversationId);
171
+ pendingRestartByConversation.delete(ownerConversationId);
172
+ if (pendingRestartByConversation.size === 0) {
173
+ activeRestartToken = null;
174
+ }
175
+ log.warn({ recordingId, conversationId: ownerConversationId }, 'Deferred restart cleaned up due to stop-ack timeout');
176
+ }
139
177
  }, STOP_ACK_TIMEOUT_MS);
140
178
  pendingStopTimeouts.set(recordingId, timeoutHandle);
141
179
 
@@ -143,6 +181,207 @@ export function handleRecordingStop(
143
181
  return recordingId;
144
182
  }
145
183
 
184
+ // ─── Restart ─────────────────────────────────────────────────────────────────
185
+
186
+ export interface RecordingRestartResult {
187
+ /** Whether the restart was initiated. false if no recording was active to stop. */
188
+ initiated: boolean;
189
+ /** The operation token threaded through the stop+start cycle. */
190
+ operationToken?: string;
191
+ /** Response text for the user. */
192
+ responseText: string;
193
+ /** When initiated is false, explains why the restart could not proceed. */
194
+ reason?: 'no_active_recording' | 'restart_in_progress';
195
+ }
196
+
197
+ /**
198
+ * Restart the active recording: stop the current one, then defer starting a
199
+ * new one until the client acknowledges the stop via a 'stopped' status
200
+ * callback.
201
+ *
202
+ * This prevents a race condition where the macOS client processes
203
+ * recording_stop asynchronously — if recording_start arrives before the
204
+ * async stop completes, RecordingManager.start() rejects because state is
205
+ * still active.
206
+ *
207
+ * Uses an operation token to guard against stale async completions from
208
+ * a previous restart cycle. The token is:
209
+ * 1. Generated here and stored as `activeRestartToken`
210
+ * 2. Stored in `deferredRestartByConversation` for later use
211
+ * 3. Threaded through to the new `recording_start` message when the stop ack arrives
212
+ * 4. Validated when `recording_status` callbacks arrive
213
+ *
214
+ * If the stop fails or times out, the deferred restart state is cleaned up.
215
+ */
216
+ export function handleRecordingRestart(
217
+ conversationId: string,
218
+ socket: net.Socket,
219
+ ctx: HandlerContext,
220
+ ): RecordingRestartResult {
221
+ // Generate a restart operation token for race hardening
222
+ const operationToken = uuid();
223
+
224
+ // Stop current recording (if any)
225
+ const stoppedRecordingId = handleRecordingStop(conversationId, ctx);
226
+
227
+ if (!stoppedRecordingId) {
228
+ // No active recording — check if mid-restart (state is not truly idle)
229
+ if (pendingRestartByConversation.has(conversationId)) {
230
+ log.info({ conversationId }, 'Restart requested while another restart is pending');
231
+ return {
232
+ initiated: false,
233
+ reason: 'restart_in_progress',
234
+ responseText: 'A restart is already in progress.',
235
+ };
236
+ }
237
+
238
+ log.info({ conversationId }, 'Restart requested but no active recording to stop');
239
+ return {
240
+ initiated: false,
241
+ reason: 'no_active_recording',
242
+ responseText: 'No active recording to restart.',
243
+ };
244
+ }
245
+
246
+ // Resolve the actual owner conversation ID. When conversation B requests
247
+ // a restart but the recording is owned by conversation A (cross-conversation
248
+ // restart via global fallback), the deferred restart must be keyed by A's
249
+ // conversationId because the stopped callback resolves the conversationId
250
+ // from standaloneRecordingConversationId (which maps to A, the owner).
251
+ // This lookup must happen BEFORE cleanupMaps removes the entry.
252
+ const ownerConversationId = standaloneRecordingConversationId.get(stoppedRecordingId) ?? conversationId;
253
+ if (ownerConversationId !== conversationId) {
254
+ log.info({ conversationId, ownerConversationId, stoppedRecordingId }, 'Cross-conversation restart: keying deferred restart by owner conversation');
255
+ }
256
+
257
+ // Atomically set the restart token and pending state so that:
258
+ // 1. Stale completions from a previous cycle are rejected
259
+ // 2. "no active recording" checks know we're mid-restart
260
+ activeRestartToken = operationToken;
261
+ pendingRestartByConversation.set(ownerConversationId, operationToken);
262
+
263
+ // Store the deferred restart parameters. The actual recording_start will
264
+ // be sent when the 'stopped' status callback arrives in handleRecordingStatus,
265
+ // ensuring the client has fully completed the async stop before we start.
266
+ // Keyed by ownerConversationId so the stopped handler (which resolves
267
+ // conversationId from the recording's owner) can find this entry.
268
+ deferredRestartByConversation.set(ownerConversationId, {
269
+ conversationId,
270
+ operationToken,
271
+ });
272
+
273
+ log.info({ conversationId, ownerConversationId, operationToken, stoppedRecordingId }, 'Recording restart initiated — start deferred until stop-ack');
274
+
275
+ return {
276
+ initiated: true,
277
+ operationToken,
278
+ responseText: 'Restarting screen recording.',
279
+ };
280
+ }
281
+
282
+ // ─── Pause ───────────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Pause the active recording for a conversation.
286
+ * Sends a `recording_pause` IPC message to the client.
287
+ *
288
+ * Returns the recording ID if pause was sent, or `undefined` if no active
289
+ * recording exists.
290
+ */
291
+ export function handleRecordingPause(
292
+ conversationId: string,
293
+ ctx: HandlerContext,
294
+ ): string | undefined {
295
+ let recordingId = recordingOwnerByConversation.get(conversationId);
296
+ let ownerConversationId = conversationId;
297
+
298
+ // Global fallback
299
+ if (!recordingId && recordingOwnerByConversation.size > 0) {
300
+ const [activeConv, activeRec] = [...recordingOwnerByConversation.entries()][0];
301
+ recordingId = activeRec;
302
+ ownerConversationId = activeConv;
303
+ }
304
+
305
+ if (!recordingId) {
306
+ log.debug({ conversationId }, 'No active recording to pause');
307
+ return undefined;
308
+ }
309
+
310
+ const socket = findSocketForSession(ownerConversationId, ctx)
311
+ ?? findSocketForSession(conversationId, ctx);
312
+ if (!socket) {
313
+ log.warn({ conversationId, recordingId }, 'Cannot send recording_pause: no socket bound');
314
+ return undefined;
315
+ }
316
+
317
+ ctx.send(socket, {
318
+ type: 'recording_pause',
319
+ recordingId,
320
+ });
321
+
322
+ log.info({ recordingId, conversationId }, 'Recording pause sent');
323
+ return recordingId;
324
+ }
325
+
326
+ // ─── Resume ──────────────────────────────────────────────────────────────────
327
+
328
+ /**
329
+ * Resume a paused recording for a conversation.
330
+ * Sends a `recording_resume` IPC message to the client.
331
+ *
332
+ * Returns the recording ID if resume was sent, or `undefined` if no active
333
+ * recording exists.
334
+ */
335
+ export function handleRecordingResume(
336
+ conversationId: string,
337
+ ctx: HandlerContext,
338
+ ): string | undefined {
339
+ let recordingId = recordingOwnerByConversation.get(conversationId);
340
+ let ownerConversationId = conversationId;
341
+
342
+ // Global fallback
343
+ if (!recordingId && recordingOwnerByConversation.size > 0) {
344
+ const [activeConv, activeRec] = [...recordingOwnerByConversation.entries()][0];
345
+ recordingId = activeRec;
346
+ ownerConversationId = activeConv;
347
+ }
348
+
349
+ if (!recordingId) {
350
+ log.debug({ conversationId }, 'No active recording to resume');
351
+ return undefined;
352
+ }
353
+
354
+ const socket = findSocketForSession(ownerConversationId, ctx)
355
+ ?? findSocketForSession(conversationId, ctx);
356
+ if (!socket) {
357
+ log.warn({ conversationId, recordingId }, 'Cannot send recording_resume: no socket bound');
358
+ return undefined;
359
+ }
360
+
361
+ ctx.send(socket, {
362
+ type: 'recording_resume',
363
+ recordingId,
364
+ });
365
+
366
+ log.info({ recordingId, conversationId }, 'Recording resume sent');
367
+ return recordingId;
368
+ }
369
+
370
+ // ─── State queries ───────────────────────────────────────────────────────────
371
+
372
+ /** Returns true if recording state is truly idle — no active recording and
373
+ * no pending restart. Callers should use this instead of checking maps
374
+ * directly to avoid returning "no active recording" during the stop/start
375
+ * window of a restart cycle. */
376
+ export function isRecordingIdle(): boolean {
377
+ return recordingOwnerByConversation.size === 0 && pendingRestartByConversation.size === 0;
378
+ }
379
+
380
+ /** Returns the current active restart operation token, or null if no restart is in progress. */
381
+ export function getActiveRestartToken(): string | null {
382
+ return activeRestartToken;
383
+ }
384
+
146
385
  // ─── Internal helpers ─────────────────────────────────────────────────────────
147
386
 
148
387
  /** Cancel a pending stop-acknowledgement timeout for a recording, if any. */
@@ -189,16 +428,84 @@ async function handleRecordingStatus(
189
428
  return;
190
429
  }
191
430
 
192
- // The client acknowledged this recording cancel any pending stop timeout.
193
- cancelStopTimeout(recordingId);
431
+ // ── Operation token validation for restart race hardening ──
432
+ // Only reject when BOTH sides have tokens AND they don't match. This means
433
+ // the status is from a DIFFERENT restart cycle (stale token mismatch).
434
+ // Tokenless statuses must be allowed through because during a restart cycle,
435
+ // the old recording's stopped/failed callbacks arrive without a token — they
436
+ // were started before the restart was initiated. These tokenless callbacks
437
+ // are legitimate and necessary for the deferred restart pattern (triggering
438
+ // the new recording_start after the old recording's stopped ack).
439
+ if (msg.operationToken && activeRestartToken && msg.operationToken !== activeRestartToken) {
440
+ log.warn(
441
+ { recordingId, expectedToken: activeRestartToken, receivedToken: msg.operationToken },
442
+ 'Rejecting stale recording_status — operation token mismatch (previous restart cycle)',
443
+ );
444
+ return;
445
+ }
446
+
447
+ // Cancel the stop timeout for most statuses, but NOT for a 'started' that
448
+ // won't complete the restart cycle. During restart, the stop timeout is the
449
+ // safety net that ensures deferred restart fires even if the client never
450
+ // sends 'stopped'. A stale tokenless 'started' from the old recording must
451
+ // not cancel it — otherwise restart state can leak indefinitely if the real
452
+ // 'stopped' callback is dropped.
453
+ const completesRestart = activeRestartToken
454
+ && msg.operationToken === activeRestartToken
455
+ && pendingRestartByConversation.get(conversationId) === activeRestartToken;
456
+ if (msg.status !== 'started' || completesRestart || !activeRestartToken) {
457
+ cancelStopTimeout(recordingId);
458
+ }
194
459
 
195
460
  // Use the reporting socket (which delivered this message) as the primary
196
461
  // recipient. Fall back to session-based lookup if the user switched sessions.
197
462
  const notifySocket = reportingSocket ?? findSocketForSession(conversationId, ctx);
198
463
 
199
464
  switch (msg.status) {
200
- case 'started':
465
+ case 'started': {
201
466
  log.info({ recordingId, conversationId }, 'Standalone recording confirmed started by client');
467
+
468
+ // If this was part of a restart cycle, clear the pending restart state
469
+ // now that the new recording has successfully started. Gate on matching
470
+ // operationToken to prevent a stale tokenless 'started' from an old
471
+ // recording from prematurely clearing the restart state.
472
+ if (completesRestart) {
473
+ pendingRestartByConversation.delete(conversationId);
474
+ activeRestartToken = null;
475
+ log.info({ recordingId, conversationId }, 'Restart cycle complete — new recording started');
476
+ }
477
+ break;
478
+ }
479
+
480
+ case 'restart_cancelled': {
481
+ // The user closed/canceled the source picker during a restart.
482
+ // Emit a deterministic response — never "new recording started".
483
+ log.info({ recordingId, conversationId }, 'Restart cancelled — source picker closed');
484
+
485
+ // Clean up restart state
486
+ cleanupMaps(recordingId, conversationId);
487
+ pendingRestartByConversation.delete(conversationId);
488
+ if (activeRestartToken && pendingRestartByConversation.size === 0) {
489
+ activeRestartToken = null;
490
+ }
491
+
492
+ if (notifySocket) {
493
+ ctx.send(notifySocket, {
494
+ type: 'assistant_text_delta',
495
+ text: 'Recording restart cancelled.',
496
+ sessionId: conversationId,
497
+ });
498
+ ctx.send(notifySocket, { type: 'message_complete', sessionId: conversationId });
499
+ }
500
+ break;
501
+ }
502
+
503
+ case 'paused':
504
+ log.info({ recordingId, conversationId }, 'Recording paused by client');
505
+ break;
506
+
507
+ case 'resumed':
508
+ log.info({ recordingId, conversationId }, 'Recording resumed by client');
202
509
  break;
203
510
 
204
511
  case 'stopped': {
@@ -211,6 +518,78 @@ async function handleRecordingStatus(
211
518
  // aren't blocked by thumbnail generation or attachment processing.
212
519
  cleanupMaps(recordingId, conversationId);
213
520
 
521
+ // Check for a deferred restart: if handleRecordingRestart stored
522
+ // pending start parameters for this conversation, trigger the start
523
+ // now that the client has fully completed the async stop.
524
+ const deferred = deferredRestartByConversation.get(conversationId);
525
+ if (deferred) {
526
+ deferredRestartByConversation.delete(conversationId);
527
+
528
+ // Resolve a fresh socket at stop-ack time instead of using the one
529
+ // captured at restart-request time, which may be stale (disconnected
530
+ // or replaced) by the time the async stop completes.
531
+ const freshSocket = findSocketForSession(deferred.conversationId, ctx)
532
+ ?? reportingSocket;
533
+
534
+ if (!freshSocket) {
535
+ log.warn({ conversationId, requesterId: deferred.conversationId }, 'Deferred restart aborted — no socket available at stop-ack time');
536
+ activeRestartToken = null;
537
+ pendingRestartByConversation.delete(conversationId);
538
+ break;
539
+ }
540
+
541
+ log.info({ recordingId, conversationId, operationToken: deferred.operationToken }, 'Stop-ack received — triggering deferred restart start');
542
+
543
+ const newRecordingId = handleRecordingStart(
544
+ deferred.conversationId,
545
+ { promptForSource: true },
546
+ freshSocket,
547
+ ctx,
548
+ deferred.operationToken,
549
+ );
550
+
551
+ if (!newRecordingId) {
552
+ // Start failed — clean up restart state
553
+ activeRestartToken = null;
554
+ pendingRestartByConversation.delete(conversationId);
555
+ log.warn({ conversationId }, 'Deferred restart start failed after stop-ack');
556
+ } else {
557
+ // Cross-conversation restart: the pendingRestartByConversation entry
558
+ // is keyed by the old owner (conversationId), but the new recording
559
+ // is owned by the requester (deferred.conversationId). Migrate the
560
+ // entry so the 'started' handler can find it under the correct key.
561
+ const startAckKey = conversationId !== deferred.conversationId
562
+ ? deferred.conversationId : conversationId;
563
+ if (conversationId !== deferred.conversationId) {
564
+ pendingRestartByConversation.delete(conversationId);
565
+ pendingRestartByConversation.set(startAckKey, deferred.operationToken);
566
+ log.info({ oldKey: conversationId, newKey: startAckKey }, 'Migrated pendingRestartByConversation key from owner to requester');
567
+ }
568
+ log.info({ conversationId, newRecordingId, operationToken: deferred.operationToken }, 'Deferred restart recording started');
569
+
570
+ // Safety timeout: if the 'started' ack doesn't arrive within 30s,
571
+ // clear restart state to prevent wedging. Without this, a dropped
572
+ // 'started' callback leaves pendingRestartByConversation stuck and
573
+ // blocks all future restart requests with 'restart_in_progress'.
574
+ const expectedToken = deferred.operationToken;
575
+ setTimeout(() => {
576
+ if (pendingRestartByConversation.get(startAckKey) === expectedToken) {
577
+ pendingRestartByConversation.delete(startAckKey);
578
+ if (activeRestartToken === expectedToken) {
579
+ activeRestartToken = null;
580
+ }
581
+ log.warn({ conversationId: startAckKey, operationToken: expectedToken },
582
+ 'Restart start-ack timeout — clearing stale restart state');
583
+ }
584
+ }, 30_000);
585
+ }
586
+
587
+ // Skip normal file-attachment flow for the old recording during
588
+ // a restart — the user initiated a stop+start cycle, not a
589
+ // deliberate "stop and save".
590
+ break;
591
+ }
592
+
214
593
  // Finalize: attach the recording file to the conversation
215
594
  if (msg.filePath) {
216
595
  // Restrict accepted file paths to the app's recordings directory to
@@ -252,7 +631,7 @@ async function handleRecordingStatus(
252
631
  if (notifySocket) {
253
632
  ctx.send(notifySocket, {
254
633
  type: 'assistant_text_delta',
255
- text: 'Recording file is unavailable or expired.',
634
+ text: 'Recording failed to save.',
256
635
  sessionId: conversationId,
257
636
  });
258
637
  ctx.send(notifySocket, { type: 'message_complete', sessionId: conversationId });
@@ -260,6 +639,19 @@ async function handleRecordingStatus(
260
639
  } else {
261
640
  const stat = statSync(resolvedPath);
262
641
  const sizeBytes = stat.size;
642
+
643
+ if (sizeBytes === 0) {
644
+ log.error({ recordingId, filePath: msg.filePath }, 'Recording file is zero-length — treating as failed');
645
+ if (notifySocket) {
646
+ ctx.send(notifySocket, {
647
+ type: 'assistant_text_delta',
648
+ text: 'Recording failed to save.',
649
+ sessionId: conversationId,
650
+ });
651
+ ctx.send(notifySocket, { type: 'message_complete', sessionId: conversationId });
652
+ }
653
+ break;
654
+ }
263
655
  const filename = path.basename(resolvedPath);
264
656
 
265
657
  // Infer MIME type from extension
@@ -365,6 +757,17 @@ async function handleRecordingStatus(
365
757
  }
366
758
 
367
759
  cleanupMaps(recordingId, conversationId);
760
+
761
+ // If this failure was part of a restart cycle, clear restart state
762
+ // including any deferred start that will never fire
763
+ deferredRestartByConversation.delete(conversationId);
764
+ if (pendingRestartByConversation.has(conversationId)) {
765
+ pendingRestartByConversation.delete(conversationId);
766
+ if (pendingRestartByConversation.size === 0) {
767
+ activeRestartToken = null;
768
+ }
769
+ }
770
+
368
771
  break;
369
772
  }
370
773
  }
@@ -392,8 +795,14 @@ export function cleanupRecordingsOnDisconnect(
392
795
  cancelStopTimeout(recId);
393
796
  standaloneRecordingConversationId.delete(recId);
394
797
  recordingOwnerByConversation.delete(convId);
798
+ pendingRestartByConversation.delete(convId);
799
+ deferredRestartByConversation.delete(convId);
395
800
  }
396
801
  }
802
+ // Clear restart token if all pending restarts were cleaned up
803
+ if (pendingRestartByConversation.size === 0) {
804
+ activeRestartToken = null;
805
+ }
397
806
  }
398
807
 
399
808
  // ─── Test helpers ────────────────────────────────────────────────────────────
@@ -406,6 +815,9 @@ export function __resetRecordingState(): void {
406
815
  pendingStopTimeouts.clear();
407
816
  standaloneRecordingConversationId.clear();
408
817
  recordingOwnerByConversation.clear();
818
+ pendingRestartByConversation.clear();
819
+ deferredRestartByConversation.clear();
820
+ activeRestartToken = null;
409
821
  }
410
822
 
411
823
  // ─── Export handler group ────────────────────────────────────────────────────