@vellumai/assistant 0.3.13 → 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/ARCHITECTURE.md +17 -3
- package/README.md +2 -0
- package/docs/architecture/scheduling.md +81 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
- package/src/__tests__/channel-policy.test.ts +19 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +584 -0
- package/src/__tests__/intent-routing.test.ts +22 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/notification-routing-intent.test.ts +186 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +181 -0
- package/src/__tests__/recording-intent-handler.test.ts +593 -73
- package/src/__tests__/recording-intent.test.ts +739 -343
- package/src/__tests__/recording-state-machine.test.ts +1109 -0
- package/src/__tests__/reminder-store.test.ts +20 -18
- package/src/__tests__/reminder.test.ts +2 -1
- package/src/channels/config.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
- package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
- package/src/config/system-prompt.ts +5 -0
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +136 -62
- package/src/daemon/ipc-contract/computer-use.ts +23 -3
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-contract/shared.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/recording-executor.ts +180 -0
- package/src/daemon/recording-intent-fallback.ts +132 -0
- package/src/daemon/recording-intent.ts +306 -15
- package/src/daemon/session-tool-setup.ts +4 -0
- package/src/notifications/README.md +69 -1
- package/src/notifications/adapters/sms.ts +80 -0
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +3 -3
- package/src/notifications/decision-engine.ts +70 -1
- package/src/notifications/decisions-store.ts +24 -0
- package/src/notifications/destination-resolver.ts +2 -1
- package/src/notifications/emit-signal.ts +35 -3
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/types.ts +3 -0
- package/src/schedule/scheduler.ts +15 -3
- package/src/tools/executor.ts +29 -0
- package/src/tools/guardian-control-plane-policy.ts +141 -0
- 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
|
-
//
|
|
193
|
-
|
|
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
|
|
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 ────────────────────────────────────────────────────
|