@ixo/oracle-runtime-workers 0.21.1 → 0.22.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.
@@ -8,11 +8,14 @@
8
8
  * → the shell routes the upgrade to this user's object (`?userDid`);
9
9
  * the object accepts the socket (Hibernatable WebSockets API) and
10
10
  * answers the engine.io OPEN handshake.
11
- * 40{"invocation":"…","ucanDelegation":"…"}
11
+ * 40{"invocation":"…","ucanDelegation":"…"[,"executor":"background","tools":[…]]}
12
12
  * → the CONNECT packet carries the same UCAN material the HTTP routes
13
13
  * take in headers; it is validated with the shell's `authenticate`
14
14
  * and the validated DID must be the routed one. Success: `40{sid}` +
15
15
  * a `connected` event. Failure: `44{message}` and the socket closes.
16
+ * `executor: "background"` declares a socket that only runs the
17
+ * browser tools it names and renders nothing (no fan-out); any other
18
+ * socket is a foreground executor.
16
19
  * 42["ping"] / 42["status"] / 42["list-events"]
17
20
  * → Node's diagnostic events, same payloads.
18
21
  * 42["tool_result",{toolCallId,result,error?}] / 42["action_call_result",…]
@@ -21,15 +24,16 @@
21
24
  *
22
25
  * Outbound, every event the runtime emits for a session (`ctx.emit.*`,
23
26
  * `tool_call`, `render_component`, …) is fanned out to the session's
24
- * authenticated sockets — the router's tap.
27
+ * authenticated foreground sockets — the router's tap.
25
28
  *
26
29
  * Frontend calls (`ctx.frontend.callBrowserTool` / `callAgAction`) are not
27
30
  * fanned out: each invocation gets its own id, is sent to ONE socket of its
28
- * session (the one most recently active), and only that socket's answer
29
- * settles it. When the
30
- * answer cannot come — deadline, or that socket went — the call resolves
31
- * with `FRONTEND_OUTCOME_UNKNOWN` and is never re-sent. See
32
- * docs/frontend-bridge.md.
31
+ * session that can run it (`SessionSocketHub.executorFor`), and only that
32
+ * socket's answer settles it. With no such socket the call is parked until
33
+ * one passes CONNECT (or the park deadline / the turn's abort ends it as
34
+ * never sent). When the answer of a sent call cannot come — deadline, or
35
+ * that socket went — the call resolves with `FRONTEND_OUTCOME_UNKNOWN` and
36
+ * is never re-sent. See docs/frontend-bridge.md.
33
37
  *
34
38
  * Heartbeat: engine.io's server-initiated ping, driven by the OBJECT'S
35
39
  * ALARM rather than a timer. A timer would keep the object resident (and
@@ -47,17 +51,31 @@ import type { EventSink, SessionEventRouter } from '../do/ambient';
47
51
  import type {
48
52
  FrontendCallParams,
49
53
  FrontendCallSurface,
54
+ FrontendWaitingFor,
50
55
  } from '../plugin-api/types';
51
56
  import type { RawEventPayload } from '../core/runtime-context';
52
57
  import { frontendInvocationId } from '@ixo/common/ai/frontend-bridge';
58
+ import {
59
+ baseEnvSchema,
60
+ FRONTEND_PARK_TIMEOUT_MS_DEFAULT,
61
+ MAX_TIMER_MS,
62
+ } from '../core/env';
63
+ import { DECLARED_TOOL_NAME_PATTERN } from '../plugins/portal/declared-tools';
53
64
  import {
54
65
  FRONTEND_CALL_LABEL,
66
+ FrontendCallNotSentError,
55
67
  FrontendCallRegistry,
68
+ OPEN_CHAT_HINT,
69
+ seconds,
56
70
  type FrontendCallKind,
57
71
  type FrontendResultRejection,
58
72
  type PendingFrontendCall,
59
73
  } from './frontend-call-registry';
60
- import { SessionSocketHub, type SocketAttachment } from './session-socket-hub';
74
+ import {
75
+ canRunTool,
76
+ SessionSocketHub,
77
+ type SocketAttachment,
78
+ } from './session-socket-hub';
61
79
  import {
62
80
  decodeClientFrame,
63
81
  encodeAck,
@@ -82,6 +100,20 @@ export const MAX_PAYLOAD_BYTES = 1_000_000;
82
100
  export const HANDSHAKE_DEADLINE_MS = 10_000;
83
101
  export const BROWSER_TOOL_DEFAULT_TIMEOUT_MS = 15_000;
84
102
  export const AG_ACTION_DEFAULT_TIMEOUT_MS = 10_000;
103
+ /** Tool names a background executor's CONNECT may declare; further names are dropped. */
104
+ export const MAX_BACKGROUND_EXECUTOR_TOOLS = 24;
105
+ /**
106
+ * Workers refuses a WebSocket attachment over 2,048 bytes serialized
107
+ * (structured clone). A background executor's tool list is cut until the
108
+ * attachment's UTF-8 JSON, with the heartbeat fields at their widest, fits
109
+ * this budget: for these fields JSON is never smaller than the structured
110
+ * clone (quotes and separators against tags and lengths, 16 digits against
111
+ * 9 bytes per number, at least as many bytes per character), and the
112
+ * margin covers the clone's header. With 51-character DIDs and a 36- or
113
+ * 44-character session id, 24 names of 64 characters fit (about 1,990 JSON
114
+ * bytes, 1,930 cloned); longer identifiers cost names, never the CONNECT.
115
+ */
116
+ export const SOCKET_ATTACHMENT_BUDGET_BYTES = 2_000;
85
117
 
86
118
  /** Header the shell sets on the forwarded upgrade: the DID it routed for. */
87
119
  export const ROUTED_USER_HEADER = 'x-routed-user-did';
@@ -173,13 +205,53 @@ export interface RealtimeEndpointDeps {
173
205
  * accepted or re-adopted. The object calls `pingTick` from its alarm.
174
206
  */
175
207
  requestAlarm?: (at: number) => void;
208
+ /**
209
+ * How long a frontend call may wait unsent for a socket that can run it
210
+ * (`FRONTEND_PARK_TIMEOUT_MS`, default 300 s).
211
+ */
212
+ parkTimeoutMs?: number;
176
213
  now?: () => number;
177
214
  }
178
215
 
216
+ /**
217
+ * `FRONTEND_PARK_TIMEOUT_MS` through the base env schema's own field (the
218
+ * rule the boot validates: a whole number of ms, 1 to the 2^31−1 a timer
219
+ * takes), so the endpoint never sees anything else; a malformed var keeps
220
+ * the default, with a warning.
221
+ */
222
+ export function frontendParkTimeoutMs(
223
+ env: { FRONTEND_PARK_TIMEOUT_MS?: string | number },
224
+ log: Pick<Console, 'warn'> = console,
225
+ ): number {
226
+ const parsed = baseEnvSchema.shape.FRONTEND_PARK_TIMEOUT_MS.safeParse(
227
+ env.FRONTEND_PARK_TIMEOUT_MS,
228
+ );
229
+ if (parsed.success) return parsed.data;
230
+ log.warn(
231
+ `[realtime] FRONTEND_PARK_TIMEOUT_MS=${JSON.stringify(env.FRONTEND_PARK_TIMEOUT_MS)} is not a whole number of ms between 1 and ${MAX_TIMER_MS}; using ${FRONTEND_PARK_TIMEOUT_MS_DEFAULT}`,
232
+ );
233
+ return FRONTEND_PARK_TIMEOUT_MS_DEFAULT;
234
+ }
235
+
236
+ /** A frontend call to send; parked while no socket of its session can run it. */
237
+ interface OutgoingFrontendCall {
238
+ toolCallId: string;
239
+ kind: FrontendCallKind;
240
+ eventName: 'browser_tool_call' | 'action_call';
241
+ payload: RawEventPayload;
242
+ sessionId: string;
243
+ toolName: string;
244
+ parkedAt: number;
245
+ }
246
+
179
247
  export interface RealtimeSocketStatus {
180
248
  sid: string;
181
249
  sessionId: string;
182
250
  authenticated: boolean;
251
+ /** `background` for a socket that declared itself a background executor. */
252
+ executor: 'foreground' | 'background';
253
+ /** How many tool names a background executor kept (absent in the foreground). */
254
+ tools?: number;
183
255
  openedAt: number;
184
256
  lastPingAt: number | null;
185
257
  lastPongAt: number;
@@ -190,6 +262,8 @@ export interface RealtimeStatus {
190
262
  authenticated: number;
191
263
  sessions: number;
192
264
  pendingCalls: PendingFrontendCall[];
265
+ /** Of `pendingCalls`, the ones parked (not sent yet). */
266
+ parkedCalls: number;
193
267
  /** Finished invocations remembered to reject replays (bounded). */
194
268
  completedCalls: number;
195
269
  pingIntervalMs: number;
@@ -222,6 +296,52 @@ function readString(
222
296
  return typeof value === 'string' && value.length > 0 ? value : undefined;
223
297
  }
224
298
 
299
+ /**
300
+ * The executor kind a CONNECT declares. Only `executor: "background"` makes
301
+ * a background executor, and only then do `tools` count: each a valid tool
302
+ * name, de-duplicated, at most `MAX_BACKGROUND_EXECUTOR_TOOLS` kept. Only
303
+ * the first `MAX_BACKGROUND_EXECUTOR_TOOLS * 4` entries are looked at (a
304
+ * frame may carry up to `MAX_PAYLOAD_BYTES` of names); the rest count as
305
+ * over the cap unread.
306
+ */
307
+ function readExecutor(record: Record<string, unknown>): {
308
+ patch: Pick<SocketAttachment, 'executor' | 'tools'>;
309
+ invalid: number;
310
+ overCap: number;
311
+ } {
312
+ if (record.executor !== 'background')
313
+ return { patch: {}, invalid: 0, overCap: 0 };
314
+ const all: unknown[] = Array.isArray(record.tools) ? record.tools : [];
315
+ const declared = all.slice(0, MAX_BACKGROUND_EXECUTOR_TOOLS * 4);
316
+ const tools: string[] = [];
317
+ let invalid = 0;
318
+ let overCap = all.length - declared.length;
319
+ for (const name of declared) {
320
+ if (typeof name !== 'string' || !DECLARED_TOOL_NAME_PATTERN.test(name))
321
+ invalid += 1;
322
+ else if (tools.includes(name)) continue;
323
+ else if (tools.length >= MAX_BACKGROUND_EXECUTOR_TOOLS) overCap += 1;
324
+ else tools.push(name);
325
+ }
326
+ return { patch: { executor: 'background', tools }, invalid, overCap };
327
+ }
328
+
329
+ /**
330
+ * UTF-8 bytes of the attachment's JSON with its heartbeat fields at their
331
+ * widest: the measure `SOCKET_ATTACHMENT_BUDGET_BYTES` bounds.
332
+ */
333
+ export function socketAttachmentBytes(attachment: SocketAttachment): number {
334
+ const widest = Number.MAX_SAFE_INTEGER;
335
+ return new TextEncoder().encode(
336
+ JSON.stringify({
337
+ ...attachment,
338
+ lastPongAt: widest,
339
+ lastPingAt: widest,
340
+ lastActiveAt: widest,
341
+ }),
342
+ ).byteLength;
343
+ }
344
+
225
345
  export class RealtimeEndpoint {
226
346
  readonly hub: SessionSocketHub<WebSocket>;
227
347
 
@@ -239,8 +359,22 @@ export class RealtimeEndpoint {
239
359
 
240
360
  private readonly now: () => number;
241
361
 
362
+ private readonly parkTimeoutMs: number;
363
+
364
+ /** Parked calls by session, oldest first (see `call`). */
365
+ private readonly parked = new Map<string, OutgoingFrontendCall[]>();
366
+
367
+ /**
368
+ * Sessions whose last parked call reached its park deadline, until a
369
+ * socket of the session passes CONNECT: their calls fail fast instead of
370
+ * waiting again. In memory only (lost when the object hibernates, after
371
+ * which a call simply parks again).
372
+ */
373
+ private readonly parkExpired = new Set<string>();
374
+
242
375
  constructor(private readonly deps: RealtimeEndpointDeps) {
243
376
  this.now = deps.now ?? (() => Date.now());
377
+ this.parkTimeoutMs = deps.parkTimeoutMs ?? FRONTEND_PARK_TIMEOUT_MS_DEFAULT;
244
378
  this.calls = new FrontendCallRegistry({ now: this.now });
245
379
  this.hub = new SessionSocketHub<WebSocket>({
246
380
  now: this.now,
@@ -261,6 +395,9 @@ export class RealtimeEndpoint {
261
395
  callAgAction: (params) =>
262
396
  this.call('agui', 'action_call', params, AG_ACTION_DEFAULT_TIMEOUT_MS),
263
397
  hasClient: (sessionId) => this.hub.hasSession(sessionId),
398
+ canDispatch: (sessionId, kind, toolName) =>
399
+ this.hub.executorFor(sessionId, kind, toolName) !== undefined,
400
+ parkedFor: (sessionId) => this.parkedFor(sessionId),
264
401
  };
265
402
  // Sockets that survived hibernation or a restart.
266
403
  const restored = this.hub.restore(deps.ctx.getWebSockets());
@@ -415,11 +552,13 @@ export class RealtimeEndpoint {
415
552
  }
416
553
 
417
554
  status(): RealtimeStatus {
555
+ const pendingCalls = this.calls.list();
418
556
  return {
419
557
  sockets: this.hub.size,
420
558
  authenticated: this.hub.connectionCount(),
421
559
  sessions: this.hub.sessionCount(),
422
- pendingCalls: this.calls.list(),
560
+ pendingCalls,
561
+ parkedCalls: pendingCalls.filter((c) => c.parked).length,
423
562
  completedCalls: this.calls.completedCount,
424
563
  pingIntervalMs: PING_INTERVAL_MS,
425
564
  pingTimeoutMs: PING_TIMEOUT_MS,
@@ -428,6 +567,8 @@ export class RealtimeEndpoint {
428
567
  sid: attachment.sid,
429
568
  sessionId: attachment.sessionId,
430
569
  authenticated: Boolean(attachment.userDid),
570
+ executor: attachment.executor ?? 'foreground',
571
+ ...(attachment.tools ? { tools: attachment.tools.length } : {}),
431
572
  openedAt: attachment.openedAt,
432
573
  lastPingAt: attachment.lastPingAt ?? null,
433
574
  lastPongAt: attachment.lastPongAt,
@@ -551,21 +692,57 @@ export class RealtimeEndpoint {
551
692
  clearTimeout(timer);
552
693
  this.handshakeTimers.delete(ws);
553
694
  }
554
- this.hub.update(ws, { userDid: outcome.auth.userDid });
695
+ const executor = this.executorPatch(meta, record, outcome.auth.userDid);
696
+ this.hub.update(ws, { userDid: outcome.auth.userDid, ...executor });
555
697
  this.hub.send(ws, encodeConnectAck(meta.sid));
556
698
  this.hub.send(
557
699
  ws,
558
700
  encodeEvent('connected', {
559
701
  message: 'Connected successfully',
560
702
  sessionId: meta.sessionId,
703
+ // The executor kind this runtime accepted: a client that asked for
704
+ // `background` and does not read it back is on a runtime that
705
+ // ignores the field (and treats the socket as a foreground tab).
706
+ executor: executor.executor ?? 'foreground',
561
707
  timestamp: new Date(this.now()).toISOString(),
562
708
  }),
563
709
  );
564
710
  this.deps.logger.log(
565
- `[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid})`,
711
+ `[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid}${executor.executor ? `, background executor of ${executor.tools?.length ?? 0} tool(s)` : ''})`,
566
712
  );
567
713
  // From here the heartbeat (the object's alarm) keeps the socket honest.
568
714
  this.scheduleHeartbeat();
715
+ this.dispatchParked(ws);
716
+ }
717
+
718
+ /**
719
+ * The executor fields a CONNECT sets on the attachment: none for a
720
+ * foreground socket; for a background one its tools, cut further when the
721
+ * attachment would not fit `SOCKET_ATTACHMENT_BUDGET_BYTES`. One warning
722
+ * names how many names were dropped and why (never the names).
723
+ */
724
+ private executorPatch(
725
+ meta: SocketAttachment,
726
+ record: Record<string, unknown>,
727
+ userDid: string,
728
+ ): Pick<SocketAttachment, 'executor' | 'tools'> {
729
+ const { patch, invalid, overCap } = readExecutor(record);
730
+ const tools = patch.tools;
731
+ if (!tools) return patch;
732
+ let overBudget = 0;
733
+ while (
734
+ tools.length > 0 &&
735
+ socketAttachmentBytes({ ...meta, userDid, ...patch }) >
736
+ SOCKET_ATTACHMENT_BUDGET_BYTES
737
+ ) {
738
+ tools.pop();
739
+ overBudget += 1;
740
+ }
741
+ if (invalid + overCap + overBudget > 0)
742
+ this.deps.logger.warn(
743
+ `[realtime] background executor ${meta.sid} of session ${meta.sessionId}: kept ${tools.length} tool name(s), dropped ${invalid} invalid, ${overCap} over the cap of ${MAX_BACKGROUND_EXECUTOR_TOOLS}, ${overBudget} over the attachment budget`,
744
+ );
745
+ return patch;
569
746
  }
570
747
 
571
748
  private refuse(ws: WebSocket, message: string): void {
@@ -694,10 +871,11 @@ export class RealtimeEndpoint {
694
871
 
695
872
  /**
696
873
  * One frontend invocation: a fresh id, registered before it is sent (an
697
- * immediate answer cannot be missed), sent to the most recently active
698
- * authenticated socket of the session (`executorForSession`) and bound to
699
- * it. A call that reaches no socket is
700
- * a definite failure; one whose answer never comes is an unknown outcome.
874
+ * immediate answer cannot be missed), sent to the socket of the session
875
+ * that runs it (`executorFor`) and bound to it. With no such socket it is
876
+ * parked, FIFO per session, until one passes CONNECT; a parked call that
877
+ * reaches its park deadline (or whose turn aborts) is a definite failure,
878
+ * a sent one whose answer never comes is an unknown outcome.
701
879
  */
702
880
  private call(
703
881
  kind: FrontendCallKind,
@@ -707,6 +885,18 @@ export class RealtimeEndpoint {
707
885
  ): Promise<unknown> {
708
886
  const toolCallId = frontendInvocationId(params.toolCallId);
709
887
  const label = FRONTEND_CALL_LABEL[kind];
888
+ // An earlier call of this session waited its whole park deadline and no
889
+ // socket has joined since: fail at once instead of waiting again.
890
+ if (
891
+ this.parkExpired.has(params.sessionId) &&
892
+ !this.hub.executorFor(params.sessionId, kind, params.toolName)
893
+ )
894
+ return Promise.reject(
895
+ new FrontendCallNotSentError(
896
+ 'no-browser',
897
+ `${label} ${params.toolName} was not sent: no browser has joined session ${params.sessionId} since an earlier call waited ${seconds(this.parkTimeoutMs)} s for one. ${OPEN_CHAT_HINT}`,
898
+ ),
899
+ );
710
900
  let pending: Promise<unknown>;
711
901
  try {
712
902
  pending = this.calls.open(
@@ -718,7 +908,9 @@ export class RealtimeEndpoint {
718
908
  },
719
909
  {
720
910
  timeoutMs: params.timeoutMs ?? defaultTimeoutMs,
911
+ parkTimeoutMs: this.parkTimeoutMs,
721
912
  ...(params.signal ? { signal: params.signal } : {}),
913
+ ...(params.onDispatched ? { onDispatched: params.onDispatched } : {}),
722
914
  },
723
915
  );
724
916
  } catch (err) {
@@ -737,34 +929,131 @@ export class RealtimeEndpoint {
737
929
  args: params.args,
738
930
  ...(kind === 'agui' ? { status: 'isRunning' } : {}),
739
931
  };
740
- const target = this.hub.executorForSession(params.sessionId);
741
- const userDid = target?.attachment.userDid;
742
- if (
743
- !target ||
744
- !userDid ||
745
- !this.hub.send(target.socket, encodeEvent(eventName, payload))
746
- ) {
747
- this.calls.fail(
748
- toolCallId,
749
- new Error(
750
- `${label} ${params.toolName} was not sent: no browser is connected to session ${params.sessionId}`,
751
- ),
932
+ const call: OutgoingFrontendCall = {
933
+ toolCallId,
934
+ kind,
935
+ eventName,
936
+ payload,
937
+ sessionId: params.sessionId,
938
+ toolName: params.toolName,
939
+ parkedAt: this.now(),
940
+ };
941
+ if (this.dispatch(call)) return pending;
942
+ const queue = this.parked.get(call.sessionId) ?? [];
943
+ queue.push(call);
944
+ this.parked.set(call.sessionId, queue);
945
+ // However it ends (dispatched, park deadline, abort), it leaves the
946
+ // queue. A park deadline marks the session: later calls fail fast until
947
+ // a socket of it passes CONNECT (`dispatchParked`).
948
+ pending.then(
949
+ () => this.unpark(call),
950
+ (error: unknown) => {
951
+ this.unpark(call);
952
+ if (
953
+ error instanceof FrontendCallNotSentError &&
954
+ error.reason === 'park-deadline'
955
+ )
956
+ this.parkExpired.add(call.sessionId);
957
+ },
958
+ );
959
+ this.deps.logger.log(
960
+ `[realtime] ${label} ${call.toolName} (${toolCallId}) parked: no socket of session ${call.sessionId} can run it`,
961
+ );
962
+ return pending;
963
+ }
964
+
965
+ /**
966
+ * Send `call` to the socket of its session that runs it and bind it there.
967
+ * A socket whose send throws is dropped by the hub, and the next eligible
968
+ * one is tried. False when none is left (the call stays parked).
969
+ */
970
+ private dispatch(call: OutgoingFrontendCall): boolean {
971
+ const frame = encodeEvent(call.eventName, call.payload);
972
+ for (;;) {
973
+ const target = this.hub.executorFor(
974
+ call.sessionId,
975
+ call.kind,
976
+ call.toolName,
752
977
  );
753
- return pending;
978
+ const userDid = target?.attachment.userDid;
979
+ if (!target || !userDid) return false;
980
+ if (!this.hub.send(target.socket, frame)) continue;
981
+ this.bind(call, target.attachment, userDid);
982
+ return true;
754
983
  }
755
- this.calls.dispatched(toolCallId, {
756
- sid: target.attachment.sid,
757
- sessionId: params.sessionId,
984
+ }
985
+
986
+ /** Record `call` as sent to the socket of `attachment`. */
987
+ private bind(
988
+ call: OutgoingFrontendCall,
989
+ attachment: SocketAttachment,
990
+ userDid: string,
991
+ ): void {
992
+ this.calls.dispatched(call.toolCallId, {
993
+ sid: attachment.sid,
994
+ sessionId: call.sessionId,
758
995
  userDid,
759
996
  });
760
997
  // A browser tool call is shown on the session's SSE stream too (session
761
- // sinks; the socket tap skips frontend calls, see `fanOut`). An AG-UI
762
- // invocation is not: the SSE stream already reports the action as an
763
- // `action_call` under the model's call id and closes it when the tool
764
- // ends, and a second frame under the invocation id would never be
765
- // closed — the client would show a card spinning forever.
766
- if (kind === 'browser') this.deps.router.emit(eventName, payload);
767
- return pending;
998
+ // sinks; the socket tap skips frontend calls, see `fanOut`), when it is
999
+ // actually sent. An AG-UI invocation is not: the SSE stream already
1000
+ // reports the action as an `action_call` under the model's call id and
1001
+ // closes it when the tool ends, and a second frame under the invocation
1002
+ // id would never be closed — the client would show a card spinning
1003
+ // forever.
1004
+ if (call.kind === 'browser')
1005
+ this.deps.router.emit(call.eventName, call.payload);
1006
+ }
1007
+
1008
+ /**
1009
+ * A socket of the session just passed CONNECT: every parked call it can
1010
+ * run is sent to it, oldest first (a foreground socket takes them all, a
1011
+ * background executor the tools it declared). A send that fails stops
1012
+ * the round; the rest stay parked.
1013
+ */
1014
+ private dispatchParked(ws: WebSocket): void {
1015
+ const attachment = this.hub.get(ws);
1016
+ const userDid = attachment?.userDid;
1017
+ if (!attachment || !userDid) return;
1018
+ // A browser of the session is back: its calls may park again.
1019
+ this.parkExpired.delete(attachment.sessionId);
1020
+ const queue = this.parked.get(attachment.sessionId);
1021
+ if (!queue) return;
1022
+ for (const call of [...queue]) {
1023
+ if (!this.calls.isParked(call.toolCallId)) {
1024
+ this.unpark(call);
1025
+ continue;
1026
+ }
1027
+ if (!canRunTool(attachment, call.kind, call.toolName)) continue;
1028
+ if (!this.hub.send(ws, encodeEvent(call.eventName, call.payload))) return;
1029
+ this.bind(call, attachment, userDid);
1030
+ this.unpark(call);
1031
+ this.deps.logger.log(
1032
+ `[realtime] parked ${FRONTEND_CALL_LABEL[call.kind]} ${call.toolName} (${call.toolCallId}) sent to ${attachment.sid} of session ${attachment.sessionId}`,
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ private unpark(call: OutgoingFrontendCall): void {
1038
+ const queue = this.parked.get(call.sessionId);
1039
+ if (!queue) return;
1040
+ const index = queue.indexOf(call);
1041
+ if (index >= 0) queue.splice(index, 1);
1042
+ if (queue.length === 0) this.parked.delete(call.sessionId);
1043
+ }
1044
+
1045
+ /** The session's oldest call still parked, if any. */
1046
+ private parkedFor(sessionId: string): FrontendWaitingFor | null {
1047
+ const call = this.parked
1048
+ .get(sessionId)
1049
+ ?.find((c) => this.calls.isParked(c.toolCallId));
1050
+ return call
1051
+ ? {
1052
+ kind: call.kind,
1053
+ toolName: call.toolName,
1054
+ since: new Date(call.parkedAt).toISOString(),
1055
+ }
1056
+ : null;
768
1057
  }
769
1058
 
770
1059
  /** The socket `sid` is gone: its invocations end as unknown, never re-sent. */
@@ -797,9 +1086,14 @@ export class RealtimeEndpoint {
797
1086
  const attachment = this.hub.get(ws);
798
1087
  this.hub.remove(ws);
799
1088
  if (attachment) this.executorGone(attachment.sid);
800
- // Only an authenticated socket ever counted for its session; a socket
801
- // that never passed CONNECT leaves nothing to drain.
802
- if (attachment?.userDid && !this.hub.hasSession(attachment.sessionId)) {
1089
+ // Only an authenticated foreground socket (a tab showing the chat) ever
1090
+ // counted for its session's drain; a socket that never passed CONNECT,
1091
+ // or a background executor, leaves nothing to drain.
1092
+ if (
1093
+ attachment?.userDid &&
1094
+ attachment.executor !== 'background' &&
1095
+ !this.hub.hasForegroundSession(attachment.sessionId)
1096
+ ) {
803
1097
  this.deps.onSessionDrained?.(attachment.sessionId, attachment.userDid);
804
1098
  }
805
1099
  }