@parall/parel-channel 1.51.0 → 1.52.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.
package/src/inbound.ts CHANGED
@@ -7,6 +7,13 @@ import type {
7
7
  } from '@parel/plugin-sdk';
8
8
  import { channelCapabilityEnv } from './channel-capability.js';
9
9
  import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
10
+ import {
11
+ armCatchUpEffect,
12
+ beginCatchUp,
13
+ CATCHUP_TIMER_KEY,
14
+ deferToCatchUp,
15
+ runCatchUpRound,
16
+ } from './catchup.js';
10
17
  import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
11
18
  import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
12
19
  import {
@@ -33,7 +40,6 @@ import {
33
40
  completeSourceEffect,
34
41
  conversationKey,
35
42
  createErrorStep,
36
- fetchMainSessionFresh,
37
43
  createInputStep,
38
44
  createTraceStep,
39
45
  createTypedInputStep,
@@ -47,7 +53,6 @@ import {
47
53
  readActiveEmit,
48
54
  reportingEnabled,
49
55
  REQUEST_TIMEOUT_MS,
50
- SESSION_CACHE_KEY,
51
56
  TURN_ENVELOPE_KEY,
52
57
  writeActiveEmit,
53
58
  } from './session.js';
@@ -65,10 +70,10 @@ import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
65
70
  * (broad-cover / no_action), the ledger lifecycle the chat activity
66
71
  * indicator rides on (parel-reply-contract-alignment.md; the old ack-on-emit
67
72
  * killed the indicator at turn start). Redelivery protection is the
68
- * per-source attempt fence in the durable store: on (re)connect, onOpen
69
- * sweeps `GET /dispatch` (the WS push has no replay), skips rows whose fence
70
- * is younger than EMIT_TTL_MS, and re-emits stale ones under a fresh
71
- * attempt-suffixed envelope id. FORK paths (deliverTo/spawn) keep their own
73
+ * per-source attempt fence in the durable store: the timer-driven catch-up
74
+ * job (catchup.ts — onOpen only arms it; the WS push has no replay) lists
75
+ * `GET /dispatch`, skips rows whose fence is younger than EMIT_TTL_MS, and
76
+ * re-emits stale ones under a fresh attempt-suffixed envelope id. FORK paths (deliverTo/spawn) keep their own
72
77
  * ack machinery — the pending ack doubles as spawn-failure recovery there —
73
78
  * so fork-handled rows still resolve as legacy_ack (indicator parity for
74
79
  * forked turns is a design follow-up, parel-reply-contract-alignment.md §6).
@@ -103,38 +108,32 @@ interface DispatchData {
103
108
  }
104
109
 
105
110
  /**
106
- * Connection-local hook serialization. The parel ConnectorHost dispatches
107
- * socket callbacks fire-and-forget (verified against connector-host.ts:
108
- * `onMessage: (data) => { this.handleMessage(data).catch(…) }`), so two
109
- * frames' async handling can interleave at await points inside one DO
110
- * isolate. Every read-then-write protocol here — the turnEnvelope marker,
111
+ * Connection-keyed hook serialization — a v1-host compatibility shim. The v1
112
+ * ConnectorHost dispatches socket callbacks fire-and-forget (verified against
113
+ * connector-host.ts: `onMessage: (data) => { this.handleMessage(data).catch(…) }`),
114
+ * so two frames' async handling can interleave at await points inside one
115
+ * isolate, and every read-then-write protocol here — the turnEnvelope marker,
111
116
  * fork planForkDecision→recordSpawn, pendingAck parking — assumes serial
112
- * execution, so ALL hook bodies run through this one promise chain. Same
113
- * connection ⇒ same DO isolate ⇒ same module instance, so a module-level
114
- * chain is exactly connection-scoped TODAY — if parel ever pools multiple
115
- * connections into one isolate, key this chain on ctx.connectionId before
116
- * anything else (cross-connection head-of-line blocking would be silent).
117
- * Failures don't break the chain.
117
+ * execution. Under the v2 execution model the HOST owns serialization (I1:
118
+ * one in-flight hook per connection, durable hook queue) and this chain is
119
+ * permanently length ≤1; it must stay until every connection runs v2
120
+ * (executionModelV2 flag), because deleting it on a v1 connection re-exposes
121
+ * the store read-modify-write races. Keyed by connectionId (not module
122
+ * global) so a pooled-isolate host can never cross-serialize two
123
+ * connections — the map is bounded by the isolate's connection count and is
124
+ * a discardable cache like every module global (v2 I4: correctness lives in
125
+ * host serialization + ctx.store). Failures don't break a chain.
118
126
  */
119
- let hookChain: Promise<unknown> = Promise.resolve();
120
- let chainConnectionId: string | undefined;
121
- /** Surface a violated isolation assumption as a log line, not silent
122
- * mis-serialization (see the chain doc above). */
123
- export function assertChainConnection(ctx: ConnectorContext): void {
124
- if (chainConnectionId === undefined) {
125
- chainConnectionId = ctx.connectionId;
126
- } else if (chainConnectionId !== ctx.connectionId) {
127
- console.warn(
128
- `[parel-channel] hook chain saw a second connection (${chainConnectionId} → ${ctx.connectionId}) — the one-connection-per-isolate assumption broke; key the chain on connectionId`,
129
- );
130
- chainConnectionId = ctx.connectionId;
131
- }
132
- }
133
- export function serialized<T>(fn: () => Promise<T>): Promise<T> {
134
- const next = hookChain.then(fn, fn);
135
- hookChain = next.then(
136
- () => undefined,
137
- () => undefined,
127
+ const hookChains = new Map<string, Promise<unknown>>();
128
+ export function serialized<T>(ctx: ConnectorContext, fn: () => Promise<T>): Promise<T> {
129
+ const chain = hookChains.get(ctx.connectionId) ?? Promise.resolve();
130
+ const next = chain.then(fn, fn);
131
+ hookChains.set(
132
+ ctx.connectionId,
133
+ next.then(
134
+ () => undefined,
135
+ () => undefined,
136
+ ),
138
137
  );
139
138
  return next;
140
139
  }
@@ -143,8 +142,7 @@ export function handleParallFrame(
143
142
  frame: WebSocketFrame,
144
143
  ctx: ConnectorContext,
145
144
  ): Promise<ConnectorEffect[]> {
146
- assertChainConnection(ctx);
147
- return serialized(() => handleParallFrameInner(frame, ctx));
145
+ return serialized(ctx, () => handleParallFrameInner(frame, ctx));
148
146
  }
149
147
 
150
148
  async function handleParallFrameInner(
@@ -175,34 +173,51 @@ async function handleParallFrameInner(
175
173
  return [];
176
174
  }
177
175
  if (event.type !== 'dispatch.new' || !event.data) return [];
176
+ // FIFO gate: while the incremental catch-up job is draining backlog, a live
177
+ // frame must not leapfrog it into the shared main session (cross-ordering
178
+ // would scramble session input order exactly like concurrent replay). The
179
+ // dispatch row is already durable server-side — deferToCatchUp marks the
180
+ // pass dirty (so the job's next from-the-top pass lists the row even when
181
+ // its cursor already moved past it — a redrive hint must survive being
182
+ // consumed here) and the re-arm doubles as the lost-timer self-heal: any
183
+ // live frame revives a stalled sweep.
184
+ const deferDelay = await deferToCatchUp(ctx);
185
+ if (deferDelay !== null) {
186
+ return [armCatchUpEffect(ctx, deferDelay)];
187
+ }
178
188
  try {
179
189
  return await effectsForDispatch(event.data, ctx);
180
190
  } catch (err) {
181
191
  // A transient fetch failure on one frame must not fail the frame handler —
182
- // the dispatch stays pending and the onOpen sweep replays it on reconnect.
192
+ // the dispatch stays pending and the catch-up job replays it on reconnect.
183
193
  console.error('[parel-channel] dispatch handling failed', err);
184
194
  return [];
185
195
  }
186
196
  }
187
197
 
188
198
  /**
189
- * Fork retry timers (armed by child_spawn_failed): re-plan the stranded
199
+ * Timers: the incremental catch-up job's rounds (catchup.ts) and fork retry
200
+ * timers (armed by child_spawn_failed) — the latter re-plan the stranded
190
201
  * message through the normal dispatch pipeline. onTimer returns the FULL
191
- * effect union (unlike onAgentEvent), so the re-plan can emit/spawn/ack.
192
- * The parked row is cleared first — a re-plan that fails transiently leaves
193
- * the WorkItem in `received` for the reconnect sweep, same as any dispatch.
202
+ * effect union (unlike onAgentEvent), so both can emit/spawn/ack.
203
+ * The parked fork row is cleared first — a re-plan that fails transiently
204
+ * leaves the WorkItem in `received` for the catch-up job, same as any
205
+ * dispatch.
194
206
  */
195
207
  export function handleParallTimer(
196
208
  timer: { key: string },
197
209
  ctx: ConnectorContext,
198
210
  ): Promise<ConnectorEffect[]> {
199
- return serialized(() => handleParallTimerInner(timer, ctx));
211
+ return serialized(ctx, () => handleParallTimerInner(timer, ctx));
200
212
  }
201
213
 
202
214
  async function handleParallTimerInner(
203
215
  timer: { key: string },
204
216
  ctx: ConnectorContext,
205
217
  ): Promise<ConnectorEffect[]> {
218
+ if (timer.key === CATCHUP_TIMER_KEY) {
219
+ return runCatchUpRound(ctx, (data) => effectsForDispatch(data, ctx));
220
+ }
206
221
  if (!timer.key.startsWith(FORK_RETRY_PREFIX)) return [];
207
222
  const parked = (await ctx.store?.get(timer.key).catch(() => null)) as DispatchData | null;
208
223
  if (!parked?.source_id) return [];
@@ -222,109 +237,26 @@ async function handleParallTimerInner(
222
237
  if (!rearmed) await ctx.store?.delete(timer.key).catch(() => {});
223
238
  return effects;
224
239
  } catch (err) {
240
+ // Transient re-plan failure (source fetch now throws on 5xx): drop the
241
+ // parked row like any non-rearmed outcome — the WorkItem stays received
242
+ // and the catch-up TTL re-emit self-heals, same as before the
243
+ // transient-throw contract.
225
244
  console.error('[parel-channel] fork retry re-plan failed', err);
245
+ await ctx.store?.delete(timer.key).catch(() => {});
226
246
  return [];
227
247
  }
228
248
  }
229
249
 
230
250
  /**
231
- * Sweep pending dispatches on (re)connect — the live WS push has no replay.
232
- * Paginates with the server cursor so a long outage's backlog is not silently
233
- * truncated (bounded to keep a pathological backlog from wedging onOpen; the
234
- * remainder is picked up on the next reconnect sweep).
251
+ * onOpen: O(1) by the v2 lifecycle contract (I4 — the 15s onOpen budget
252
+ * cannot hold an O(backlog) sweep, and under first-timeout-terminal a slow
253
+ * onOpen is an epoch-rotation storm). Resets the incremental catch-up job
254
+ * and arms its timer; all sweeping — the New Session generation check
255
+ * included — happens in bounded onTimer rounds (catchup.ts). The live WS
256
+ * push still has no replay: the job is the only redelivery path.
235
257
  */
236
- export function catchUpParall(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
237
- return serialized(() => catchUpParallInner(ctx));
238
- }
239
-
240
- async function catchUpParallInner(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
241
- // New Session generation check (must run before any dispatch replay): the
242
- // `agent.new_session` WS push is not replayed, so a reset that happened
243
- // while this connection was down would leave stale fork routing behind —
244
- // post-reset messages would land in pre-reset children. The main ase_ IS
245
- // the generation: New Session closes it and the next get-or-create mints a
246
- // fresh id, so compare the cached id against the server's current one and
247
- // purge fork state on rotation. Every missed-event scenario passes through
248
- // onOpen (a live socket delivers the event; a dead one reconnects here).
249
- // The cached id is never dropped before the fresh (bypass-cache) read
250
- // succeeds — it is the only generation token, and losing it to a transient
251
- // /sessions failure would silently skip the purge until the fork TTLs
252
- // expire.
253
- try {
254
- const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null)) as string | null;
255
- if (cachedMain) {
256
- const freshMain = await fetchMainSessionFresh(ctx);
257
- if (freshMain && freshMain !== cachedMain) {
258
- const refs = await clearForkState(ctx);
259
- for (const ref of refs) {
260
- await invalidateChildSession(ctx, ref);
261
- }
262
- console.warn('[parel-channel] main session rotated while offline — fork state purged');
263
- }
264
- }
265
- // Capability revalidation: a parked forkDisabled latch reflects the
266
- // binding shape at failure time; the fleet sweep converges bindings
267
- // without restarting the connection, so a reconnect is the natural
268
- // revalidation point. If forking is truly still disabled, the first
269
- // spawn attempt re-parks the latch at the cost of one failed effect.
270
- await ctx.store?.delete('forkDisabled').catch(() => {});
271
- } catch (err) {
272
- console.error('[parel-channel] new-session generation check failed', err);
273
- }
274
- const effects: ConnectorEffect[] = [];
275
- try {
276
- const apiUrl = parallApiUrl(ctx);
277
- const agk = requireAgk(ctx);
278
- const orgId = parallOrgId(ctx);
279
- let cursor = '';
280
- // Page bound is a wedge guard, not a coverage target. Unknown dispatch
281
- // types stay pending (deliberately — see effectsForDispatch) and the
282
- // list is FIFO, so the bound must comfortably exceed any realistic
283
- // unsupported backlog or old rows could starve newer DMs out of the sweep.
284
- // 40 pages ≈ 2000 dispatches per sweep; log when we hit the ceiling so a
285
- // pathological backlog is visible instead of silent.
286
- const maxPages = 40;
287
- let page = 0;
288
- for (; page < maxPages; page++) {
289
- const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
290
- const res = await fetch(url, {
291
- headers: { Authorization: `Bearer ${agk}` },
292
- signal: AbortSignal.timeout(10_000),
293
- });
294
- if (!res.ok) break;
295
- const body = (await res.json().catch(() => null)) as {
296
- data?: DispatchData[];
297
- next_cursor?: string;
298
- } | null;
299
- // ALL dispatches replay SEQUENTIALLY, in page order (FIFO): every
300
- // envelope feeds the agent's one main session, so cross-chat replay
301
- // concurrency would scramble the session's input order AND race the
302
- // agent-global turnEnvelope marker (a slower old dispatch could
303
- // overwrite a newer one's marker and mis-arm the done-signal guard).
304
- // Reconnect backlogs are small; serialization is the correctness
305
- // posture, not a throughput concern. A failed dispatch resolves to []
306
- // and stays pending for the next sweep.
307
- for (const d of body?.data ?? []) {
308
- const out = await effectsForDispatch(d, ctx).catch((err) => {
309
- console.error('[parel-channel] catch-up dispatch failed', err);
310
- return [] as ConnectorEffect[];
311
- });
312
- effects.push(...out);
313
- }
314
- if (!body?.next_cursor) break;
315
- cursor = body.next_cursor;
316
- }
317
- if (page === maxPages) {
318
- console.warn(
319
- '[parel-channel] catch-up hit the page ceiling; remainder deferred to the next reconnect sweep',
320
- );
321
- }
322
- } catch (err) {
323
- // Catch-up is best-effort: keep whatever pages completed and never fail
324
- // the connection open over it.
325
- console.error('[parel-channel] dispatch catch-up failed', err);
326
- }
327
- return effects;
258
+ export function handleParallOpen(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
259
+ return serialized(ctx, () => beginCatchUp(ctx));
328
260
  }
329
261
 
330
262
  /**
@@ -406,15 +338,18 @@ async function effectsForDispatch(
406
338
  // Pull the source message text (global by-id read; ACL = chat membership).
407
339
  const m = await fetch(`${apiUrl}/api/v1/messages/${sourceId}`, {
408
340
  headers: { Authorization: `Bearer ${agk}` },
409
- signal: AbortSignal.timeout(10_000),
341
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
410
342
  });
411
343
  if (!m.ok) {
412
344
  if (m.status === 404 || m.status === 403) {
413
345
  // Deleted message / lost chat access — permanently unusable, drop it.
414
346
  return [completeSourceEffect(ctx, sourceType, sourceId)];
415
347
  }
416
- // Transient failure: keep pending, the catch-up sweep retries.
417
- return [];
348
+ // Transient failure: keep pending — and THROW rather than return [], so
349
+ // the catch-up job can tell a starving row from a deliberately-pending
350
+ // one and re-arm with backoff (the live frame path catches at
351
+ // handleParallFrameInner; same visible outcome there).
352
+ throw new Error(`transient message fetch failure (${m.status})`);
418
353
  }
419
354
  const msg = (await m.json().catch(() => null)) as {
420
355
  content?: { text?: string };
@@ -450,11 +385,11 @@ async function effectsForDispatch(
450
385
  // Frame the message with its chat of origin: all chats share the agent's
451
386
  // one main session, so the model must see WHERE each message comes from or
452
387
  // it answers chat B inside chat A's train of thought. The name is cosmetic
453
- // garnish and must never block the emit/ack hot path (the catch-up sweep is
454
- // fully serialized — one slow lookup would stall the whole backlog): only
455
- // the cached name is used, and a miss kicks off a fire-and-forget prefetch
456
- // so the NEXT message from this chat gets the name. The id alone already
457
- // marks the context switch.
388
+ // garnish resolved inline under the tight miss-path timeout (cached
389
+ // per-chat afterward; a degraded lookup frames id-only — the id alone
390
+ // already marks the context switch). See cachedChatName for why inline:
391
+ // the v2 capture window drops post-hook store writes, so a background
392
+ // prefetch could never persist its cache row.
458
393
  const chatName = await cachedChatName(ctx, chatId);
459
394
  const senderId = msg?.sender_id ? String(msg.sender_id) : undefined;
460
395
  const framedText = buildChatPrompt({
@@ -547,11 +482,21 @@ async function effectsForDispatch(
547
482
  messageId: sourceId,
548
483
  senderId,
549
484
  text: stepText,
485
+ threadRootId,
550
486
  }),
551
487
  ]);
552
488
  // A closed child ase_ (New Session closed the whole family) just drops
553
489
  // the cache; the next event rebuilds it.
554
- if (results.includes('stale')) await invalidateChildSession(ctx, decision.childRef);
490
+ if (results.includes('stale')) {
491
+ await invalidateChildSession(ctx, decision.childRef);
492
+ } else {
493
+ // Child turns get the same message↔session linking as main: the CLI
494
+ // reads PRLL_SESSION_ID into its sends' agent_session_id, and the
495
+ // childEnvelope below shares this context object. A stale childAse
496
+ // must never be stamped — attribution must not point at a closed
497
+ // session (same rule as the main path's repair branch).
498
+ invocationContext.PRLL_SESSION_ID = childAse;
499
+ }
555
500
  }
556
501
  await receivedSettled;
557
502
  const childEnvelope = {
@@ -654,19 +599,21 @@ async function effectsForDispatch(
654
599
  messageId: sourceId,
655
600
  senderId,
656
601
  text: stepText,
602
+ threadRootId,
657
603
  }),
658
604
  ]);
659
605
  if (results.includes('stale')) await invalidateChildSession(ctx, decision.childRef);
660
606
  }
661
607
  await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
662
608
  await receivedSettled;
663
- // Known gap: SpawnChildSessionEffect (#140) carries no invocation
664
- // context, so the fork's OPENING turn runs without the capability env
665
- // (lark-cli workspace ops would need a follow-up turn — those arrive
666
- // as deliverTo envelopes, which inherit the full context). Channel
667
- // replies are unaffected: channel_message dispatches always ride main.
668
- // Fix belongs parel-side (context on spawnChildSession) — tracked as a
669
- // parel ask.
609
+ // Known gap: SpawnChildSessionEffect (plugin-sdk 0.12) carries no
610
+ // invocation context, so the fork's OPENING turn runs without the
611
+ // capability env (lark-cli workspace ops would need a follow-up turn)
612
+ // AND without PRLL_SESSION_ID (its reply links via dispatch provenance
613
+ // instead) — deliverTo follow-ups inherit the full context, including
614
+ // the session stamp. Channel replies are unaffected: channel_message
615
+ // dispatches always ride main. Fix belongs parel-side (context on
616
+ // spawnChildSession) — tracked as a parel ask.
670
617
  return [
671
618
  {
672
619
  type: 'spawnChildSession',
@@ -712,32 +659,45 @@ async function effectsForDispatch(
712
659
  messageId: sourceId,
713
660
  senderId,
714
661
  text: stepText,
662
+ threadRootId,
715
663
  }),
716
664
  ]);
717
665
  const results = await report(sessionId);
718
666
  if (results.includes('stale')) {
719
667
  // The cached session was closed under us (e.g. New Session). The
720
668
  // get-or-create falls through terminal rows, so one rebuild + retry
721
- // repairs reporting for this and every later dispatch.
669
+ // repairs reporting for this and every later dispatch. Attribution
670
+ // (PRLL_SESSION_ID + the fence) must never keep pointing at the closed
671
+ // session — clear it, and restore only from a successful fresh report.
722
672
  await invalidateSession(ctx);
673
+ sessionId = null;
723
674
  const fresh = await ensureSession(ctx);
724
- if (fresh && fresh !== sessionId) {
725
- await report(fresh);
726
- sessionId = fresh;
675
+ if (fresh) {
676
+ const retry = await report(fresh);
677
+ if (!retry.includes('stale')) sessionId = fresh;
727
678
  }
728
679
  }
729
680
  }
681
+ // The CLI reads PRLL_SESSION_ID into the send's agent_session_id, giving
682
+ // the reply message its ase_ provenance (message ↔ session linking; the
683
+ // server validates ownership). Injected after the session resolves — the
684
+ // envelope's context object is still ours until the emit effect returns.
685
+ if (sessionId) {
686
+ invocationContext.PRLL_SESSION_ID = sessionId;
687
+ }
730
688
  await receivedSettled;
731
689
  // Arm the attempt fence BEFORE the emit effect is returned: turn events
732
690
  // complete a source only while `active:{source}` matches their envelope id
733
691
  // exactly, and the redelivery-skip window starts at this timestamp. Only
734
692
  // the main path writes it — fork paths keep their own ack machinery.
693
+ // The fence also carries this attempt's session (attribution for the
694
+ // turn-end ledger close — see ActiveEmit).
735
695
  // Deliberate ordering tradeoff: a crash between this write and the host
736
696
  // executing the emit leaves an armed fence for an un-emitted source, so
737
697
  // the reconnect sweep hides it until the TTL re-emit (≤30min delay). The
738
698
  // reverse order (fence after emit) would instead double-emit on a crash —
739
699
  // a duplicate agent turn. Delay heals; duplicates don't.
740
- await writeActiveEmit(ctx, sourceType, sourceId, envelopeId);
700
+ await writeActiveEmit(ctx, sourceType, sourceId, envelopeId, sessionId ?? undefined);
741
701
 
742
702
  // No ack effect: the row stays 'received' until the reply (dispatch
743
703
  // _source) or the turn event (complete-sources) resolves it — the
@@ -859,55 +819,57 @@ async function effectsForTypedDispatch(
859
819
 
860
820
  /**
861
821
  * How long a cached chat name is served without revalidation. Chat renames
862
- * are rare and the frame is the model's cross-chat context anchor, so serve
863
- * stale-while-revalidate: past the TTL the cached name is still returned
864
- * immediately (never block the hot path) and a background refresh updates
865
- * the row for later messages.
822
+ * are rare and the frame is the model's cross-chat context anchor, so past
823
+ * the TTL the row is refreshed inline (bounded) and the old name serves as
824
+ * the fallback when the refresh degrades.
866
825
  */
867
826
  const CHAT_NAME_TTL_MS = 60 * 60 * 1000;
868
827
 
869
828
  /**
870
- * chat id → display name for prompt framing, NON-BLOCKING: returns only what
871
- * the durable store already holds; a miss fires a background prefetch (tight
872
- * timeout, any failure ignored) so later messages get the name. The first
873
- * message from a chat is framed id-only — acceptable, the id is the
874
- * correctness anchor and the name is garnish; a blocking lookup here would
875
- * sit on the emit/ack hot path and stall the fully-serialized catch-up sweep
876
- * one dispatch at a time. DMs have no name — the empty name is cached as a
877
- * "resolved, nameless" sentinel so they don't re-prefetch on every message.
878
- * Rows carry the fetch timestamp; past CHAT_NAME_TTL_MS the stale name is
879
- * served AND revalidated in the background, so a renamed chat converges
880
- * within one TTL window. (Pre-TTL rows were plain strings — served once and
881
- * upgraded by the same background refresh.)
829
+ * chat id → display name for prompt framing. Resolved INLINE on a miss or a
830
+ * TTL-stale row, under the tight miss-path timeout (3s): the v2 capture
831
+ * window drops any store write landing after the hook returns, so the old
832
+ * fire-and-forget prefetch would never persist its cache row — every message
833
+ * would re-prefetch and the name would never appear. Inline is bounded and
834
+ * self-limiting (per-chat cached after the first resolve; the catch-up job's
835
+ * round budget accounts for cold items). Failures degrade: a miss frames
836
+ * id-only (the id is the correctness anchor, the name is garnish), a stale
837
+ * row serves the old name. DMs have no name — the empty name is cached as a
838
+ * "resolved, nameless" sentinel so they don't re-fetch on every message.
839
+ * (Pre-TTL rows were plain strings — served once and upgraded by the same
840
+ * inline refresh.)
882
841
  */
883
842
  async function cachedChatName(ctx: ConnectorContext, chatId: string): Promise<string | undefined> {
884
843
  const cacheKey = `chatName:${chatId}`;
885
844
  const cached = await ctx.store?.get(cacheKey).catch(() => null);
845
+ let staleName: string | undefined;
886
846
  if (
887
847
  cached &&
888
848
  typeof cached === 'object' &&
889
849
  typeof (cached as { name?: unknown }).name === 'string'
890
850
  ) {
891
851
  const { name, at } = cached as { name: string; at?: number };
892
- if (typeof at !== 'number' || ctx.now() - at > CHAT_NAME_TTL_MS) {
893
- void prefetchChatName(ctx, chatId, cacheKey).catch(() => {});
852
+ if (typeof at === 'number' && ctx.now() - at <= CHAT_NAME_TTL_MS) {
853
+ return name || undefined;
894
854
  }
895
- return name || undefined;
855
+ staleName = name || undefined;
856
+ } else if (typeof cached === 'string') {
857
+ // Legacy plain-string row: refresh inline below, serve it as fallback.
858
+ staleName = cached || undefined;
896
859
  }
897
- if (typeof cached === 'string') {
898
- // Legacy plain-string row: serve it, upgrade to the timestamped shape.
899
- void prefetchChatName(ctx, chatId, cacheKey).catch(() => {});
900
- return cached || undefined;
901
- }
902
- void prefetchChatName(ctx, chatId, cacheKey).catch(() => {});
903
- return undefined;
860
+ const fetched = await fetchChatName(ctx, chatId, cacheKey);
861
+ // null means the lookup degraded (serve the stale name if we have one);
862
+ // undefined means resolved-and-nameless (DM sentinel / cleared name) and
863
+ // must NOT fall back to a stale name — `??` would conflate the two.
864
+ return fetched === null ? staleName : fetched;
904
865
  }
905
866
 
906
- async function prefetchChatName(
867
+ /** Resolve + cache one chat's name; null when the lookup degrades. */
868
+ async function fetchChatName(
907
869
  ctx: ConnectorContext,
908
870
  chatId: string,
909
871
  cacheKey: string,
910
- ): Promise<void> {
872
+ ): Promise<string | undefined | null> {
911
873
  try {
912
874
  const res = await fetch(
913
875
  `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/chats/${chatId}`,
@@ -916,12 +878,14 @@ async function prefetchChatName(
916
878
  signal: AbortSignal.timeout(PROVIDER_LOOKUP_TIMEOUT_MS),
917
879
  },
918
880
  );
919
- if (!res.ok) return;
881
+ if (!res.ok) return null;
920
882
  const body = (await res.json().catch(() => null)) as { name?: string } | null;
921
883
  const name = typeof body?.name === 'string' ? body.name : '';
922
884
  await ctx.store?.set(cacheKey, { name, at: ctx.now() }).catch(() => {});
885
+ return name || undefined;
923
886
  } catch {
924
- // Cosmetic prefetch — the next message from this chat retries.
887
+ // Cosmetic lookup — the next message from this chat retries.
888
+ return null;
925
889
  }
926
890
  }
927
891
 
@@ -946,7 +910,7 @@ export function handleParallAgentEvent(
946
910
  event: AgentEvent,
947
911
  ctx: ConnectorContext,
948
912
  ): Promise<AgentEventEffect[]> {
949
- return serialized(() => handleParallAgentEventInner(event, ctx));
913
+ return serialized(ctx, () => handleParallAgentEventInner(event, ctx));
950
914
  }
951
915
 
952
916
  async function handleParallAgentEventInner(
package/src/index.ts CHANGED
@@ -2,9 +2,9 @@ import type { ChannelConnector } from '@parel/plugin-sdk';
2
2
  import { connectParall } from './connect.js';
3
3
  import { buildParallDelivery } from './delivery.js';
4
4
  import {
5
- catchUpParall,
6
5
  handleParallAgentEvent,
7
6
  handleParallFrame,
7
+ handleParallOpen,
8
8
  handleParallTimer,
9
9
  } from './inbound.js';
10
10
 
@@ -24,7 +24,7 @@ import {
24
24
  */
25
25
  const connector: ChannelConnector = {
26
26
  connect: connectParall,
27
- onOpen: catchUpParall,
27
+ onOpen: handleParallOpen,
28
28
  onMessage: handleParallFrame,
29
29
  onTimer: handleParallTimer,
30
30
  onAgentEvent: handleParallAgentEvent,