@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { randomInt } from "node:crypto";
10
10
 
11
+ import type { AdmissionPolicy } from "@vellumai/gateway-client";
11
12
  import type { ServerWebSocket } from "bun";
12
13
 
13
14
  import {
@@ -47,6 +48,7 @@ import {
47
48
  updateCallSession,
48
49
  } from "./call-store.js";
49
50
  import { ConversationRelayTransport } from "./call-transport.js";
51
+ import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
50
52
  import { finalizeCall } from "./finalize-call.js";
51
53
  import {
52
54
  classifyWaitUtterance,
@@ -170,6 +172,11 @@ export function setRelayBroadcast(fn: (msg: ServerMessage) => void): void {
170
172
  */
171
173
  type RelayConnectionState =
172
174
  | "connected"
175
+ // Transient state held synchronously from the start of handleSetup until
176
+ // routing/ACL completes. Prompts arriving in this window are dropped so a
177
+ // not-yet-authorized caller's speech is never persisted/emitted before the
178
+ // admission floor and trust ACL are applied.
179
+ | "setting_up"
173
180
  | "verification_pending"
174
181
  | "awaiting_name"
175
182
  | "awaiting_guardian_decision"
@@ -550,15 +557,58 @@ export class RelayConnection {
550
557
  "ConversationRelay setup received",
551
558
  );
552
559
 
560
+ // Enter the setup-pending state synchronously, before any await. The
561
+ // admission-policy read below yields the event loop, and inbound frames are
562
+ // dispatched fire-and-forget, so a `prompt` frame can arrive before routing
563
+ // completes. handlePrompt drops prompts while in "setting_up" so a blocked
564
+ // or admission-denied caller's speech is never stored before the ACL runs.
565
+ this.connectionState = "setting_up";
566
+
553
567
  const session = getCallSession(this.callSessionId);
554
568
  this.recordSetupBookkeeping(session, msg);
555
569
 
570
+ // Resolve the phone channel's inbound admission floor. The reader fails
571
+ // open to `null` by contract, so a transport hiccup admits the caller.
572
+ const admissionPolicy = await getChannelAdmissionPolicy("phone");
573
+
574
+ try {
575
+ await this.routeSetupOutcome(msg, session, admissionPolicy);
576
+ } catch (err) {
577
+ // Never leave the connection stranded in "setting_up": a setup that
578
+ // throws before reaching a terminal outcome would otherwise drop every
579
+ // prompt forever. Tear the call down instead.
580
+ log.error(
581
+ { err, callSessionId: this.callSessionId },
582
+ "Setup routing failed — disconnecting",
583
+ );
584
+ this.connectionState = "disconnecting";
585
+ updateCallSession(this.callSessionId, {
586
+ status: "failed",
587
+ endedAt: Date.now(),
588
+ lastError: "Setup routing failed",
589
+ });
590
+ this.endSession("Setup routing failed");
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Apply the routing/ACL outcome for a setup frame. Synchronous helpers that
596
+ * fire-and-forget (greeting, verification, name capture) are awaited here only
597
+ * when they themselves are async. Each reachable outcome transitions the
598
+ * connection out of "setting_up" into its terminal state.
599
+ */
600
+ private async routeSetupOutcome(
601
+ msg: RelaySetupMessage,
602
+ session: ReturnType<typeof getCallSession>,
603
+ admissionPolicy: AdmissionPolicy | null,
604
+ ): Promise<void> {
556
605
  const { outcome, resolved } = routeSetup({
557
606
  callSessionId: this.callSessionId,
558
607
  session,
559
608
  from: msg.from,
560
609
  to: msg.to,
561
610
  customParameters: msg.customParameters,
611
+ admissionPolicy,
562
612
  });
563
613
 
564
614
  const initialTrustContext = toTrustContext(
@@ -634,6 +684,9 @@ export class RelayConnection {
634
684
  );
635
685
  }
636
686
  }
687
+ // Routing/ACL cleared — leave the setup-pending state so subsequent
688
+ // prompts are handled normally.
689
+ this.connectionState = "connected";
637
690
  this.startNormalCallFlow(controller, outcome.isInbound);
638
691
  return;
639
692
  }
@@ -1822,6 +1875,13 @@ export class RelayConnection {
1822
1875
  return;
1823
1876
  }
1824
1877
 
1878
+ // Prompts arriving before setup routing/ACL completes are dropped — never
1879
+ // persisted or emitted — so a not-yet-authorized caller's speech can't be
1880
+ // stored ahead of the admission floor / trust ACL decision.
1881
+ if (this.connectionState === "setting_up") {
1882
+ return;
1883
+ }
1884
+
1825
1885
  if (!msg.last) {
1826
1886
  // Partial transcript, wait for final
1827
1887
  return;
@@ -2024,6 +2084,12 @@ export class RelayConnection {
2024
2084
  return;
2025
2085
  }
2026
2086
 
2087
+ // Drop DTMF arriving before setup routing/ACL completes (same reasoning as
2088
+ // handlePrompt): don't record input from a not-yet-authorized caller.
2089
+ if (this.connectionState === "setting_up") {
2090
+ return;
2091
+ }
2092
+
2027
2093
  // Ignore DTMF during name capture and guardian decision wait
2028
2094
  if (
2029
2095
  this.connectionState === "awaiting_name" ||
@@ -6,6 +6,8 @@
6
6
  * next — without performing any side effects itself.
7
7
  */
8
8
 
9
+ import type { AdmissionPolicy } from "@vellumai/gateway-client";
10
+
9
11
  import { getConfig } from "../config/loader.js";
10
12
  import { findActiveVoiceInvites } from "../memory/invite-store.js";
11
13
  import {
@@ -14,6 +16,10 @@ import {
14
16
  } from "../runtime/actor-trust-resolver.js";
15
17
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
16
18
  import { getPendingSession } from "../runtime/channel-verification-service.js";
19
+ import {
20
+ type AdmissionPolicyResult,
21
+ enforceAdmissionPolicy,
22
+ } from "../runtime/routes/inbound-stages/admission-policy.js";
17
23
  import { getLogger } from "../util/logger.js";
18
24
  import type { CallSession } from "./types.js";
19
25
 
@@ -27,6 +33,12 @@ interface SetupContext {
27
33
  from: string;
28
34
  to: string;
29
35
  customParameters?: Record<string, string>;
36
+ /**
37
+ * Per-channel inbound admission floor for the `phone` channel, supplied by
38
+ * the caller. When absent/`null`, the floor check is skipped entirely —
39
+ * preserving all pre-admission behavior.
40
+ */
41
+ admissionPolicy?: AdmissionPolicy | null;
30
42
  }
31
43
 
32
44
  // ── Setup outcomes ───────────────────────────────────────────────────
@@ -184,6 +196,50 @@ export function routeSetup(ctx: SetupContext): {
184
196
  // ── Inbound call ACL evaluation ─────────────────────────────────
185
197
  const pendingChallenge = getPendingSession("phone");
186
198
 
199
+ // An admission floor is "active" only when a policy applies and no pending
200
+ // verification challenge is in flight. While active, the floor IS the access
201
+ // decision: an admitted caller bypasses the legacy identity flows
202
+ // (unverified_caller / name_capture) and connects directly. When inactive
203
+ // (null policy, flag off, exempt channel, reader failed open, or a pending
204
+ // challenge), those legacy flows are preserved unchanged.
205
+ const floorActive = ctx.admissionPolicy != null && !pendingChallenge;
206
+
207
+ // Inbound admission floor verdict; defaults to admitted when inactive.
208
+ const floorVerdict = floorActive
209
+ ? enforceAdmissionPolicy({
210
+ sourceChannel: "phone",
211
+ trustClass: actorTrust.trustClass,
212
+ memberStatus: actorTrust.memberRecord?.channel.status,
213
+ policy: ctx.admissionPolicy!,
214
+ })
215
+ : ({ admitted: true } as const);
216
+
217
+ // Floor-deny outcome shared by the unknown-caller and member-caller branches.
218
+ // Live calls cannot await async re-verification, so the floor's
219
+ // `shouldChallenge` upgrade UX is not surfaced — same rationale as `escalate`.
220
+ const floorDeny = (
221
+ denyVerdict: Extract<AdmissionPolicyResult, { admitted: false }>,
222
+ ) => {
223
+ log.info(
224
+ {
225
+ callSessionId: ctx.callSessionId,
226
+ from: ctx.from,
227
+ trustClass: actorTrust.trustClass,
228
+ effectivePolicy: denyVerdict.effectivePolicy,
229
+ },
230
+ "Inbound voice ACL: admission floor denied caller",
231
+ );
232
+ return {
233
+ outcome: {
234
+ action: "deny" as const,
235
+ message:
236
+ "This number is not authorized to reach the assistant right now.",
237
+ logReason: `Inbound voice admission floor: ${denyVerdict.effectivePolicy}`,
238
+ },
239
+ resolved,
240
+ };
241
+ };
242
+
187
243
  if (
188
244
  (actorTrust.trustClass === "unknown" ||
189
245
  actorTrust.trustClass === "unverified_contact") &&
@@ -245,6 +301,21 @@ export function routeSetup(ctx: SetupContext): {
245
301
  };
246
302
  }
247
303
 
304
+ // When a floor is active it is the access decision: an admitted caller
305
+ // connects directly (skipping unverified_caller / name_capture), and a
306
+ // below-floor caller is denied. Invites (handled above) bypass the floor
307
+ // as an explicit grant. When the floor is inactive (null policy), fall
308
+ // through to the legacy identity flows below.
309
+ if (floorActive) {
310
+ if (!floorVerdict.admitted) {
311
+ return floorDeny(floorVerdict);
312
+ }
313
+ return {
314
+ outcome: { action: "normal_call" as const, isInbound: true },
315
+ resolved,
316
+ };
317
+ }
318
+
248
319
  // Known caller whose channel hasn't passed verification yet —
249
320
  // mirrors the gateway's pre-intercept (twilio-voice-webhook.ts) so
250
321
  // calls slipping past it (e.g. canonicalization mismatch between
@@ -349,6 +420,12 @@ export function routeSetup(ctx: SetupContext): {
349
420
  };
350
421
  }
351
422
 
423
+ // Admission floor: deny member/guardian callers below the floor (e.g.
424
+ // `guardian_only` denies a trusted_contact).
425
+ if (!floorVerdict.admitted) {
426
+ return floorDeny(floorVerdict);
427
+ }
428
+
352
429
  // Guardian and trusted-contact callers proceed normally
353
430
  return {
354
431
  outcome: { action: "normal_call", isInbound: true },
@@ -54,6 +54,7 @@ import {
54
54
  releaseCallbackClaim,
55
55
  updateCallSession,
56
56
  } from "./call-store.js";
57
+ import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
57
58
  import { routeSetup } from "./relay-setup-router.js";
58
59
  import { resolveCallHints } from "./stt-hints.js";
59
60
  import { resolveTelephonySttRouting } from "./telephony-stt-routing.js";
@@ -277,10 +278,10 @@ const TWIML_HEADERS = { "Content-Type": "text/xml" } as const;
277
278
  * query for outbound calls). Returns a TwiML string. Throws RouteError
278
279
  * subclasses on failure.
279
280
  */
280
- function processVoiceWebhook(
281
+ async function processVoiceWebhook(
281
282
  params: Record<string, string>,
282
283
  callSessionId: string | null,
283
- ): string {
284
+ ): Promise<string> {
284
285
  const callSid = params.CallSid ?? null;
285
286
  const callerFrom = params.From ?? "";
286
287
  const callerTo = params.To ?? "";
@@ -371,7 +372,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
371
372
  const params = Object.fromEntries(formBody.entries());
372
373
 
373
374
  try {
374
- return twimlResponse(processVoiceWebhook(params, callSessionId));
375
+ return twimlResponse(await processVoiceWebhook(params, callSessionId));
375
376
  } catch (err) {
376
377
  if (err instanceof RouteError) {
377
378
  return new Response(err.message, { status: err.statusCode });
@@ -399,7 +400,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
399
400
  * the Twilio setup payload. The persisted call session mode is the
400
401
  * primary signal for deterministic flow selection in the relay server.
401
402
  */
402
- function buildVoiceWebhookTwiml(
403
+ async function buildVoiceWebhookTwiml(
403
404
  callSessionId: string,
404
405
  sessionContext: {
405
406
  task: string | null;
@@ -410,7 +411,7 @@ function buildVoiceWebhookTwiml(
410
411
  inviteGuardianName: string | null;
411
412
  } | null,
412
413
  verificationSessionId?: string | null,
413
- ): string {
414
+ ): Promise<string> {
414
415
  const cfg = loadConfig();
415
416
  const profile = resolveVoiceQualityProfile(cfg);
416
417
 
@@ -476,11 +477,19 @@ function buildVoiceWebhookTwiml(
476
477
  const from = sessionContext?.fromNumber ?? "";
477
478
  const to = sessionContext?.toNumber ?? "";
478
479
 
480
+ // Resolve the phone channel's inbound admission floor so this preflight
481
+ // classification matches the real enforcement at stream start — a
482
+ // floor-denied caller is recognized as `deny` here, not `normal_call`.
483
+ // The reader fails open to `null` by contract, so a transport hiccup
484
+ // admits the caller.
485
+ const admissionPolicy = await getChannelAdmissionPolicy("phone");
486
+
479
487
  const { outcome } = routeSetup({
480
488
  callSessionId,
481
489
  session: session ?? null,
482
490
  from,
483
491
  to,
492
+ admissionPolicy,
484
493
  });
485
494
 
486
495
  // The media-stream transport supports normal_call and deny (which speaks
@@ -780,9 +789,9 @@ const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response/>';
780
789
  * Internal voice-webhook handler for gateway→runtime forwarding.
781
790
  * Accepts JSON body `{ params, originalUrl? }` from the gateway.
782
791
  */
783
- export function handleInternalVoiceWebhook({
792
+ export async function handleInternalVoiceWebhook({
784
793
  body = {},
785
- }: RouteHandlerArgs): RouteResponse {
794
+ }: RouteHandlerArgs): Promise<RouteResponse> {
786
795
  const { params = {}, originalUrl } = body as {
787
796
  params?: Record<string, string>;
788
797
  originalUrl?: string;
@@ -798,7 +807,7 @@ export function handleInternalVoiceWebhook({
798
807
  }
799
808
  }
800
809
 
801
- const twiml = processVoiceWebhook(params, callSessionId);
810
+ const twiml = await processVoiceWebhook(params, callSessionId);
802
811
  return new RouteResponse(twiml, TWIML_HEADERS);
803
812
  }
804
813
 
@@ -187,6 +187,67 @@ export interface AssistantSurface {
187
187
  activationMoment?: ActivationMomentParam;
188
188
  }
189
189
 
190
+ // ── abort watchdog ───────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Generous backstop that drives an aborted turn to its `finally` even if some
194
+ * awaited operation fails to observe the abort signal.
195
+ *
196
+ * Abort is otherwise cooperative and already wired into the slow paths: the
197
+ * provider call forwards the signal to its HTTP/streaming fetch, and tool
198
+ * execution races the signal so a stuck tool can't block cancellation. This
199
+ * watchdog only fires when a future code path silently ignores abort — without
200
+ * it, such a path would hang the loop forever and latch the conversation's
201
+ * `processing` flag true (the wedged "Thinking…" indicator). It is
202
+ * defense-in-depth, not the primary mechanism, so the timeout is deliberately
203
+ * generous; in the common case abort settles in-flight work well before it.
204
+ */
205
+ const ABORT_WATCHDOG_MS = 45_000;
206
+
207
+ /**
208
+ * Race `work` against an abort watchdog. The watchdog stays disarmed until the
209
+ * signal fires; once it does, the turn has `timeoutMs` to settle before the
210
+ * watchdog rejects with the signal's own abort reason so the caller unwinds to
211
+ * its `finally` and the reason classifies as a user cancellation downstream.
212
+ * The abandoned `work` promise keeps running detached — its eventual rejection
213
+ * is swallowed so it can't surface as an unhandled rejection.
214
+ */
215
+ async function withAbortWatchdog<T>(
216
+ work: Promise<T>,
217
+ signal: AbortSignal,
218
+ timeoutMs: number,
219
+ onFire: () => void,
220
+ ): Promise<T> {
221
+ let timer: ReturnType<typeof setTimeout> | undefined;
222
+ let onAbort: (() => void) | undefined;
223
+ const watchdog = new Promise<never>((_, reject) => {
224
+ const arm = () => {
225
+ timer = setTimeout(() => {
226
+ onFire();
227
+ // Propagate the signal's own reason (an `AbortReason` for daemon-owned
228
+ // cancels) so the loop's catch classifies this as a user cancellation;
229
+ // fall back to a tagged AbortError when the signal carries no reason.
230
+ reject(
231
+ signal.reason ??
232
+ new DOMException("The operation was aborted", "AbortError"),
233
+ );
234
+ }, timeoutMs);
235
+ };
236
+ if (signal.aborted) arm();
237
+ else {
238
+ onAbort = arm;
239
+ signal.addEventListener("abort", onAbort, { once: true });
240
+ }
241
+ });
242
+ try {
243
+ return await Promise.race([work, watchdog]);
244
+ } finally {
245
+ if (timer) clearTimeout(timer);
246
+ if (onAbort) signal.removeEventListener("abort", onAbort);
247
+ void work.catch(() => {});
248
+ }
249
+ }
250
+
190
251
  // ── runAgentLoop ─────────────────────────────────────────────────────
191
252
 
192
253
  export async function runAgentLoopImpl(
@@ -902,22 +963,36 @@ export async function runAgentLoopImpl(
902
963
  msgs: Message[],
903
964
  compactInPlace = false,
904
965
  ): Promise<Message[]> => {
905
- const { history, exitReason, newMessages } = await ctx.agentLoop.run({
906
- messages: msgs,
907
- onEvent: eventHandler,
908
- signal: abortController.signal,
909
- requestId: reqId,
910
- onCheckpoint,
911
- callSite: turnCallSite,
912
- trust: loopTrust,
913
- overrideProfile: turnOverrideProfile,
914
- ...(forceOverrideProfile ? { forceOverrideProfile: true } : {}),
915
- resolveOverrideProfile: resolveCurrentOverrideProfile,
916
- resolveContextWindow,
917
- compactInPlace,
918
- isNonInteractive,
919
- modelProfileKey,
920
- });
966
+ const watchdogMs = ctx.abortWatchdogMs ?? ABORT_WATCHDOG_MS;
967
+ const { history, exitReason, newMessages } = await withAbortWatchdog(
968
+ ctx.agentLoop.run({
969
+ messages: msgs,
970
+ onEvent: eventHandler,
971
+ signal: abortController.signal,
972
+ requestId: reqId,
973
+ onCheckpoint,
974
+ callSite: turnCallSite,
975
+ trust: loopTrust,
976
+ overrideProfile: turnOverrideProfile,
977
+ ...(forceOverrideProfile ? { forceOverrideProfile: true } : {}),
978
+ resolveOverrideProfile: resolveCurrentOverrideProfile,
979
+ resolveContextWindow,
980
+ compactInPlace,
981
+ isNonInteractive,
982
+ modelProfileKey,
983
+ }),
984
+ abortController.signal,
985
+ watchdogMs,
986
+ () =>
987
+ rlog.error(
988
+ {
989
+ conversationId: ctx.conversationId,
990
+ requestId: reqId,
991
+ timeoutMs: watchdogMs,
992
+ },
993
+ "Abort watchdog fired — agent loop did not settle after cancel; forcing turn to finally",
994
+ ),
995
+ );
921
996
  lastRunNewMessages = newMessages;
922
997
  if (exitReason === "handoff") {
923
998
  yieldedForHandoff = true;
@@ -1450,46 +1525,37 @@ export async function runAgentLoopImpl(
1450
1525
 
1451
1526
  ctx.profiler.emitSummary(ctx.traceEmitter, reqId);
1452
1527
 
1453
- // A user cancel clears `processing` immediately so the UI unblocks even
1454
- // when this turn is slow to unwind, which lets a new turn start and install
1455
- // its own AbortController, processing flag, request id, and queued work
1456
- // before this turn reaches here. The shared per-turn state below belongs to
1457
- // whichever turn currently owns the conversation, so tear it down only while
1458
- // this turn is still the owner. Each turn captures its AbortController at
1459
- // start, so a newer turn having replaced `ctx.abortController` means it has
1460
- // taken ownership; in that case leave the state intact so the new turn stays
1461
- // cancellable and visible.
1462
- const supersededByNewerTurn =
1463
- ctx.abortController !== null && ctx.abortController !== abortController;
1464
- if (!supersededByNewerTurn) {
1465
- ctx.abortController = null;
1466
- ctx.setProcessing(false);
1467
- ctx.onConfirmationOutcome = undefined;
1468
- ctx.surfaceActionRequestIds.delete(ctx.currentRequestId ?? "");
1469
- ctx.approvedViaPromptThisTurn = false;
1470
- ctx.currentRequestId = undefined;
1471
- ctx.currentActiveSurfaceId = undefined;
1472
- ctx.allowedToolNames = undefined;
1473
- ctx.diskPressureCleanupModeActive = false;
1474
- ctx.preactivatedSkillIds = undefined;
1475
- ctx.currentTurnOverrideProfile = undefined;
1476
- // Channel command intents (e.g. Telegram /start) are single-turn metadata.
1477
- // Clear at turn end so they never leak into subsequent unrelated messages.
1478
- ctx.commandIntent = undefined;
1479
- // taskRunId scopes ephemeral task-run permissions to a single turn. Clear
1480
- // before drainQueue so queued/drained turns on a reused conversation can't
1481
- // inherit stale in-task-run scope from the turn that just finished.
1482
- ctx.taskRunId = undefined;
1483
-
1484
- // Consolidation deferred to compaction: keeping assistant + tool_result
1485
- // messages unconsolidated preserves the exact message structure sent to
1486
- // the API, enabling stable prefix caching across turns. Compaction
1487
- // consolidates when it summarizes old messages (cache miss is expected).
1488
-
1489
- ctx.drainQueue(
1490
- yieldedForHandoff ? "checkpoint_handoff" : "loop_complete",
1491
- );
1492
- }
1528
+ // Tear down this turn's per-turn state. Abort reliably drives the loop to
1529
+ // this `finally` within a bounded time — cooperative signal propagation
1530
+ // (provider fetch + tool race) backed by the abort watchdog — so a
1531
+ // cancelled turn always unwinds before any resend can start a new one.
1532
+ // There is therefore only ever one turn alive, and clearing the shared
1533
+ // state below cannot clobber a concurrent turn.
1534
+ ctx.abortController = null;
1535
+ ctx.setProcessing(false);
1536
+ ctx.onConfirmationOutcome = undefined;
1537
+ ctx.surfaceActionRequestIds.delete(ctx.currentRequestId ?? "");
1538
+ ctx.approvedViaPromptThisTurn = false;
1539
+ ctx.currentRequestId = undefined;
1540
+ ctx.currentActiveSurfaceId = undefined;
1541
+ ctx.allowedToolNames = undefined;
1542
+ ctx.diskPressureCleanupModeActive = false;
1543
+ ctx.preactivatedSkillIds = undefined;
1544
+ ctx.currentTurnOverrideProfile = undefined;
1545
+ // Channel command intents (e.g. Telegram /start) are single-turn metadata.
1546
+ // Clear at turn end so they never leak into subsequent unrelated messages.
1547
+ ctx.commandIntent = undefined;
1548
+ // taskRunId scopes ephemeral task-run permissions to a single turn. Clear
1549
+ // before drainQueue so queued/drained turns on a reused conversation can't
1550
+ // inherit stale in-task-run scope from the turn that just finished.
1551
+ ctx.taskRunId = undefined;
1552
+
1553
+ // Consolidation deferred to compaction: keeping assistant + tool_result
1554
+ // messages unconsolidated preserves the exact message structure sent to
1555
+ // the API, enabling stable prefix caching across turns. Compaction
1556
+ // consolidates when it summarizes old messages (cache miss is expected).
1557
+
1558
+ ctx.drainQueue(yieldedForHandoff ? "checkpoint_handoff" : "loop_complete");
1493
1559
 
1494
1560
  // Clear conversation tags so they don't leak into unrelated error captures
1495
1561
  // (e.g. unhandledRejection from a different async chain).
@@ -394,6 +394,13 @@ export class Conversation {
394
394
  workspaceDir: string,
395
395
  ) => Pick<WorkspaceGitService, "ensureInitialized">;
396
396
  /** @internal */ commitTurnChanges?: typeof commitTurnChanges;
397
+ /**
398
+ * Abort-watchdog timeout (ms) for the agent loop's bounded-unwind backstop.
399
+ * Overridable in tests to fire the watchdog quickly; defaults to the
400
+ * production constant in the agent loop when unset.
401
+ * @internal
402
+ */
403
+ abortWatchdogMs?: number;
397
404
  /**
398
405
  * The conversation's immutable creation type (`interactive`, `background`,
399
406
  * `scheduled`, …) as stored on the DB row. Cached on load (and set directly
@@ -106,14 +106,12 @@ export function cancelGeneration(conversationId: string): boolean {
106
106
  // being cancelled, so enqueuing synthetic messages would trigger
107
107
  // unwanted model activity after the user pressed stop.
108
108
  getSubagentManager().abortAllForParent(conversationId);
109
- // Clear the processing flag so every client stops rendering the thinking
110
- // indicator. abort() only signals the AbortController, so a turn that never
111
- // observes the signal leaves `processing` latched true; clearing it here
112
- // publishes the metadata sync invalidation that drives clients to the
113
- // authoritative state. A turn that does eventually unwind tears down only the
114
- // shared turn state it still owns, so this early clear neither double-fires
115
- // the invalidation nor clobbers a turn started after the cancel.
116
- conversation.setProcessing(false);
109
+ // The processing flag is cleared by the in-flight turn's `finally`, not here.
110
+ // Abort propagates into the provider call and tool execution (and is backed
111
+ // by the agent loop's abort watchdog), so the turn reaches its `finally`
112
+ // within a bounded time and tears down its own state there — which publishes
113
+ // the metadata sync invalidation that drives clients to the authoritative
114
+ // idle state.
117
115
  return true;
118
116
  }
119
117
 
@@ -33,11 +33,13 @@ describe("enforceAdmissionPolicy — exempt channels", () => {
33
33
  expect(result.admitted).toBe(true);
34
34
  });
35
35
 
36
- test("phone short-circuits to admitted (§8.4: voice ingress not wired)", () => {
36
+ test("phone is enforced (not exempt): the floor applies", () => {
37
+ // Voice ingress is wired, so `phone` is no longer exempt — an `unknown`
38
+ // caller is denied under `no_one`.
37
39
  const result = enforceAdmissionPolicy(
38
40
  makeInput({ sourceChannel: "phone", trustClass: "unknown", policy: "no_one" }),
39
41
  );
40
- expect(result.admitted).toBe(true);
42
+ expect(result.admitted).toBe(false);
41
43
  });
42
44
  });
43
45