@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/dist/catchup.d.ts +79 -0
- package/dist/catchup.d.ts.map +1 -0
- package/dist/catchup.js +296 -0
- package/dist/connect.d.ts.map +1 -1
- package/dist/connect.js +4 -2
- package/dist/fork.d.ts +7 -0
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +14 -2
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +55 -38
- package/dist/inbound.d.ts +14 -13
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +142 -180
- package/dist/index.js +2 -2
- package/dist/session.d.ts +37 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -19
- package/dist/typed-dispatch.d.ts +12 -6
- package/dist/typed-dispatch.d.ts.map +1 -1
- package/dist/typed-dispatch.js +65 -53
- package/package.json +1 -1
- package/src/catchup.ts +337 -0
- package/src/connect.ts +4 -2
- package/src/fork.ts +17 -2
- package/src/inbound-channel.ts +59 -44
- package/src/inbound.ts +156 -192
- package/src/index.ts +2 -2
- package/src/session.ts +80 -19
- package/src/typed-dispatch.ts +63 -46
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:
|
|
69
|
-
*
|
|
70
|
-
* is younger than EMIT_TTL_MS, and
|
|
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-
|
|
107
|
-
* socket callbacks fire-and-forget (verified against
|
|
108
|
-
* `onMessage: (data) => { this.handleMessage(data).catch(…) }`),
|
|
109
|
-
* frames' async handling can interleave at await points inside one
|
|
110
|
-
* isolate
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
192
|
-
* The parked row is cleared first — a re-plan that fails transiently
|
|
193
|
-
* the WorkItem in `received` for the
|
|
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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
|
237
|
-
return serialized(() =>
|
|
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(
|
|
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
|
|
417
|
-
|
|
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
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
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'))
|
|
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 (
|
|
664
|
-
// context, so the fork's OPENING turn runs without the
|
|
665
|
-
// (lark-cli workspace ops would need a follow-up turn
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
// parel
|
|
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
|
|
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
|
|
863
|
-
*
|
|
864
|
-
*
|
|
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
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
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
|
|
893
|
-
|
|
852
|
+
if (typeof at === 'number' && ctx.now() - at <= CHAT_NAME_TTL_MS) {
|
|
853
|
+
return name || undefined;
|
|
894
854
|
}
|
|
895
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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:
|
|
27
|
+
onOpen: handleParallOpen,
|
|
28
28
|
onMessage: handleParallFrame,
|
|
29
29
|
onTimer: handleParallTimer,
|
|
30
30
|
onAgentEvent: handleParallAgentEvent,
|