@parall/parel-channel 1.40.0 → 1.42.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/inbound.js CHANGED
@@ -1,7 +1,47 @@
1
- import { buildChannelPrompt, buildChatPrompt } from './channel-prompt.js';
2
- import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
- import { createChannelInputStep, createErrorStep, createInputStep, createTraceStep, ensureSession, invalidateSession, markDispatchReceived, patchSession, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, } from './session.js';
4
- export async function handleParallFrame(frame, ctx) {
1
+ import { buildChatPrompt } from './channel-prompt.js';
2
+ import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
3
+ import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
4
+ import { buildForkScopePrefix, bumpForkRetryAttempts, childConfirmed, childRefKnown, clearForkState, clearForkSubject, FORK_RETRY_DELAY_MS, FORK_RETRY_PREFIX, handleChildSpawnFailed, MAX_SPAWN_VERIFY_ATTEMPTS, noteMainTurnEvent, planForkDecision, recordSpawn, settleChildEvent, trackFollowUpAck, } from './fork.js';
5
+ import { asChildSpawnFailed, asConnectorEffects, } from './parel-sdk-compat.js';
6
+ import { ackByIdEffect, ackEffect, completeFencedEnvelopes, conversationKey, createErrorStep, fetchMainSessionFresh, createInputStep, createTraceStep, createTypedInputStep, EMIT_TTL_MS, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, markDispatchReceived, nextEnvelopeId, patchSession, readActiveEmit, reportingEnabled, SESSION_CACHE_KEY, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
7
+ import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
8
+ /**
9
+ * Connection-local hook serialization. The parel ConnectorHost dispatches
10
+ * socket callbacks fire-and-forget (verified against connector-host.ts:
11
+ * `onMessage: (data) => { this.handleMessage(data).catch(…) }`), so two
12
+ * frames' async handling can interleave at await points inside one DO
13
+ * isolate. Every read-then-write protocol here — the turnEnvelope marker,
14
+ * fork planForkDecision→recordSpawn, pendingAck parking — assumes serial
15
+ * execution, so ALL hook bodies run through this one promise chain. Same
16
+ * connection ⇒ same DO isolate ⇒ same module instance, so a module-level
17
+ * chain is exactly connection-scoped TODAY — if parel ever pools multiple
18
+ * connections into one isolate, key this chain on ctx.connectionId before
19
+ * anything else (cross-connection head-of-line blocking would be silent).
20
+ * Failures don't break the chain.
21
+ */
22
+ let hookChain = Promise.resolve();
23
+ let chainConnectionId;
24
+ /** Surface a violated isolation assumption as a log line, not silent
25
+ * mis-serialization (see the chain doc above). */
26
+ export function assertChainConnection(ctx) {
27
+ if (chainConnectionId === undefined) {
28
+ chainConnectionId = ctx.connectionId;
29
+ }
30
+ else if (chainConnectionId !== ctx.connectionId) {
31
+ console.warn(`[parel-channel] hook chain saw a second connection (${chainConnectionId} → ${ctx.connectionId}) — the one-connection-per-isolate assumption broke; key the chain on connectionId`);
32
+ chainConnectionId = ctx.connectionId;
33
+ }
34
+ }
35
+ export function serialized(fn) {
36
+ const next = hookChain.then(fn, fn);
37
+ hookChain = next.then(() => undefined, () => undefined);
38
+ return next;
39
+ }
40
+ export function handleParallFrame(frame, ctx) {
41
+ assertChainConnection(ctx);
42
+ return serialized(() => handleParallFrameInner(frame, ctx));
43
+ }
44
+ async function handleParallFrameInner(frame, ctx) {
5
45
  let event;
6
46
  try {
7
47
  const raw = typeof frame.data === 'string' ? frame.data : new TextDecoder().decode(frame.data);
@@ -10,6 +50,23 @@ export async function handleParallFrame(frame, ctx) {
10
50
  catch {
11
51
  return [];
12
52
  }
53
+ if (event.type === 'agent.new_session') {
54
+ // New Session rotated the main conversation line (server-driven binding
55
+ // reset). Stale fork routing surviving it would deliver post-reset
56
+ // messages into pre-reset children (old context) or fork off a busy
57
+ // marker for a session that no longer exists — purge it all, and drop
58
+ // the cached fork ase_ rows so the next event resolves fresh ones.
59
+ try {
60
+ const refs = await clearForkState(ctx);
61
+ for (const ref of refs) {
62
+ await invalidateChildSession(ctx, ref);
63
+ }
64
+ }
65
+ catch (err) {
66
+ console.error('[parel-channel] new-session fork purge failed', err);
67
+ }
68
+ return [];
69
+ }
13
70
  if (event.type !== 'dispatch.new' || !event.data)
14
71
  return [];
15
72
  try {
@@ -22,21 +79,94 @@ export async function handleParallFrame(frame, ctx) {
22
79
  return [];
23
80
  }
24
81
  }
82
+ /**
83
+ * Fork retry timers (armed by child_spawn_failed): re-plan the stranded
84
+ * message through the normal dispatch pipeline. onTimer returns the FULL
85
+ * effect union (unlike onAgentEvent), so the re-plan can emit/spawn/ack.
86
+ * The parked row is cleared first — a re-plan that fails transiently leaves
87
+ * the WorkItem in `received` for the reconnect sweep, same as any dispatch.
88
+ */
89
+ export function handleParallTimer(timer, ctx) {
90
+ return serialized(() => handleParallTimerInner(timer, ctx));
91
+ }
92
+ async function handleParallTimerInner(timer, ctx) {
93
+ if (!timer.key.startsWith(FORK_RETRY_PREFIX))
94
+ return [];
95
+ const parked = (await ctx.store?.get(timer.key).catch(() => null));
96
+ if (!parked?.source_id)
97
+ return [];
98
+ // Re-planning is idempotent on every path — an already-acked opening hits
99
+ // the confirmed branch (ack re-send, harmless), an unconfirmed one bumps
100
+ // the bounded verification counter, deliverTo replays dedupe on envelope
101
+ // id — so the timer re-drives unconditionally.
102
+ try {
103
+ const effects = await effectsForDispatch(parked, ctx);
104
+ // The parked row's only live purpose is the attempts counter across
105
+ // verification rounds — signaled by the re-plan re-arming THIS key. Any
106
+ // other outcome (confirmed ack / deliverTo / main / give-up) resolved
107
+ // the message, and the row would otherwise leak forever (no TTL).
108
+ const rearmed = effects.some((e) => e.type === 'setTimer' && e.key === timer.key);
109
+ if (!rearmed)
110
+ await ctx.store?.delete(timer.key).catch(() => { });
111
+ return effects;
112
+ }
113
+ catch (err) {
114
+ console.error('[parel-channel] fork retry re-plan failed', err);
115
+ return [];
116
+ }
117
+ }
25
118
  /**
26
119
  * Sweep pending dispatches on (re)connect — the live WS push has no replay.
27
120
  * Paginates with the server cursor so a long outage's backlog is not silently
28
121
  * truncated (bounded to keep a pathological backlog from wedging onOpen; the
29
122
  * remainder is picked up on the next reconnect sweep).
30
123
  */
31
- export async function catchUpParall(ctx) {
124
+ export function catchUpParall(ctx) {
125
+ return serialized(() => catchUpParallInner(ctx));
126
+ }
127
+ async function catchUpParallInner(ctx) {
128
+ // New Session generation check (must run before any dispatch replay): the
129
+ // `agent.new_session` WS push is not replayed, so a reset that happened
130
+ // while this connection was down would leave stale fork routing behind —
131
+ // post-reset messages would land in pre-reset children. The main ase_ IS
132
+ // the generation: New Session closes it and the next get-or-create mints a
133
+ // fresh id, so compare the cached id against the server's current one and
134
+ // purge fork state on rotation. Every missed-event scenario passes through
135
+ // onOpen (a live socket delivers the event; a dead one reconnects here).
136
+ // The cached id is never dropped before the fresh (bypass-cache) read
137
+ // succeeds — it is the only generation token, and losing it to a transient
138
+ // /sessions failure would silently skip the purge until the fork TTLs
139
+ // expire.
140
+ try {
141
+ const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null));
142
+ if (cachedMain) {
143
+ const freshMain = await fetchMainSessionFresh(ctx);
144
+ if (freshMain && freshMain !== cachedMain) {
145
+ const refs = await clearForkState(ctx);
146
+ for (const ref of refs) {
147
+ await invalidateChildSession(ctx, ref);
148
+ }
149
+ console.warn('[parel-channel] main session rotated while offline — fork state purged');
150
+ }
151
+ }
152
+ // Capability revalidation: a parked forkDisabled latch reflects the
153
+ // binding shape at failure time; the fleet sweep converges bindings
154
+ // without restarting the connection, so a reconnect is the natural
155
+ // revalidation point. If forking is truly still disabled, the first
156
+ // spawn attempt re-parks the latch at the cost of one failed effect.
157
+ await ctx.store?.delete('forkDisabled').catch(() => { });
158
+ }
159
+ catch (err) {
160
+ console.error('[parel-channel] new-session generation check failed', err);
161
+ }
32
162
  const effects = [];
33
163
  try {
34
164
  const apiUrl = parallApiUrl(ctx);
35
165
  const agk = requireAgk(ctx);
36
166
  const orgId = parallOrgId(ctx);
37
167
  let cursor = '';
38
- // Page bound is a wedge guard, not a coverage target. Non-message
39
- // dispatches stay pending (deliberately — see effectsForDispatch) and the
168
+ // Page bound is a wedge guard, not a coverage target. Unknown dispatch
169
+ // types stay pending (deliberately — see effectsForDispatch) and the
40
170
  // list is FIFO, so the bound must comfortably exceed any realistic
41
171
  // unsupported backlog or old rows could starve newer DMs out of the sweep.
42
172
  // 40 pages ≈ 2000 dispatches per sweep; log when we hit the ceiling so a
@@ -82,6 +212,25 @@ export async function catchUpParall(ctx) {
82
212
  }
83
213
  return effects;
84
214
  }
215
+ /**
216
+ * Turn-in-flight guard for redelivery (catch-up sweep AND repeated
217
+ * dispatch.new pushes). Main-path rows stay live (not acked) for the whole
218
+ * turn now, and GET /dispatch lists pending AND received rows — so redelivery
219
+ * re-sees every in-flight source. Skip any row whose attempt fence is younger
220
+ * than EMIT_TTL_MS, REGARDLESS of row status: a transiently-failed
221
+ * /dispatch/received leaves the row `pending` while the emit succeeded (fence
222
+ * written), and re-emitting it inside the TTL would duplicate the in-flight
223
+ * turn — complete-sources resolves pending rows just as well, so the fence
224
+ * alone is the truth. Past the TTL (crashed emit, lost turn events) fall
225
+ * through and re-emit with a fresh attempt-suffixed envelope id. Brand-new
226
+ * sources have no fence and never skip; fork-handled rows never write a
227
+ * fence (their ack machinery owns redelivery there).
228
+ * Design: parel-reply-contract-alignment.md §5.3.
229
+ */
230
+ async function shouldSkipLiveRow(ctx, sourceType, sourceId) {
231
+ const active = await readActiveEmit(ctx, sourceType, sourceId);
232
+ return active !== null && ctx.now() - active.at < EMIT_TTL_MS;
233
+ }
85
234
  async function effectsForDispatch(data, ctx) {
86
235
  const sourceType = String(data.source_type ?? '');
87
236
  const sourceId = data.source_id ? String(data.source_id) : undefined;
@@ -90,19 +239,45 @@ async function effectsForDispatch(data, ctx) {
90
239
  if (sourceType === 'channel_message' && sourceId) {
91
240
  // External IM message (chat_id is NULL by design — the conversation
92
241
  // lives on the provider, not in a Parall chat).
93
- return effectsForChannelDispatch(sourceId, dispatchId, ctx);
242
+ if (await shouldSkipLiveRow(ctx, sourceType, sourceId))
243
+ return [];
244
+ return effectsForChannelDispatch(sourceId, ctx);
245
+ }
246
+ const eventType = String(data.event_type ?? '');
247
+ if (sourceId && isTypedEventType(eventType)) {
248
+ // Legacy configs without parallAgentId predate the whole reporting
249
+ // surface AND the typed handlers' stale guards (planTask compares the
250
+ // assignee against it) — emitting there would deliver reassigned tasks
251
+ // and record nothing. Keep typed work pending until the connection is
252
+ // redeployed on the current shape (same posture as unknown types).
253
+ if (!reportingEnabled(ctx)) {
254
+ return [];
255
+ }
256
+ // Conversational-only deployments (no sandbox → no `parall` CLI) cannot
257
+ // act on typed work AT ALL under the CLI-only reply contract — nothing
258
+ // auto-delivers anymore, approval_decided included — so emitting AND
259
+ // acking would silently consume real work. Keep it pending (same
260
+ // posture as unknown types). Defensive-only: the server's mandatory
261
+ // sandbox gate (422 PAREL_SANDBOX_REQUIRED) means no such managed
262
+ // agent can be provisioned.
263
+ if (!sandboxEnabled(ctx)) {
264
+ return [];
265
+ }
266
+ return effectsForTypedDispatch(data, ctx);
94
267
  }
95
- // Non-message dispatches (task_assign, schedule.fire, …) stay PENDING on
96
- // purpose: they are meaningful work a future connector version will handle,
97
- // and acking would silently drop it. Message dispatches that can NEVER
98
- // become processable are acked-and-dropped below instead, so the pending
99
- // queue (and the reconnect catch-up sweep) is bounded by real future work.
268
+ // Unknown non-message dispatches stay PENDING on purpose: they are
269
+ // meaningful work a future connector version will handle, and acking would
270
+ // silently drop it. Message dispatches that can NEVER become processable
271
+ // are acked-and-dropped below instead, so the pending queue (and the
272
+ // reconnect catch-up sweep) is bounded by real future work.
100
273
  if (sourceType !== 'message' || !sourceId)
101
274
  return [];
102
275
  if (!chatId) {
103
276
  // A message dispatch with no chat can never be routed or replied to.
104
277
  return [ackEffect(ctx, sourceType, sourceId)];
105
278
  }
279
+ if (await shouldSkipLiveRow(ctx, sourceType, sourceId))
280
+ return [];
106
281
  const apiUrl = parallApiUrl(ctx);
107
282
  const agk = requireAgk(ctx);
108
283
  const orgId = parallOrgId(ctx);
@@ -123,22 +298,250 @@ async function effectsForDispatch(data, ctx) {
123
298
  const text = msg?.content?.text ?? '';
124
299
  // A thread reply anchors the agent's answer to the same thread.
125
300
  const threadRootId = msg?.thread_root_id || undefined;
126
- if (!text) {
127
- // Attachment-only / empty-text messages never grow text — MVP has no
128
- // attachment path, so ack-and-drop rather than let them pile up in the
129
- // pending queue forever.
301
+ // Attachments render as reference lines — the agent knows they exist and
302
+ // pulls the bytes itself via its sandbox CLI (standard-runtime contract).
303
+ const attachments = (msg?.attachments ?? [])
304
+ .filter((a) => typeof a?.id === 'string' && a.id)
305
+ .map((a) => ({
306
+ id: String(a.id),
307
+ fileName: String(a.file_name ?? ''),
308
+ fileSize: typeof a.file_size === 'number' ? a.file_size : 0,
309
+ mimeType: String(a.mime_type ?? ''),
310
+ }));
311
+ if (!text && attachments.length === 0) {
312
+ // Truly empty messages never grow content — ack-and-drop rather than let
313
+ // them pile up in the pending queue forever. (Attachment-only messages
314
+ // used to take this path; they now ride the attachment lines instead.)
130
315
  return [ackEffect(ctx, sourceType, sourceId)];
131
316
  }
132
- // Progress reporting (best-effort, parall-runtime-indicator-plan.md §3):
133
- // received lights the pending ring, the active+trigger patch arms the done
134
- // signal, the input step flips the ring to processing. received MUST settle
135
- // before this function returns — the ack effect below moves the dispatch
136
- // row out of 'pending', and MarkReceived only transitions pending rows.
137
- // It is deliberately NOT gated on the session resolving: it only touches
138
- // the dispatch row, and a transient session failure must not also cost the
139
- // pending ring (a later successful turn's idle clears agent-level pendings).
317
+ // Frame the message with its chat of origin: all chats share the agent's
318
+ // one main session, so the model must see WHERE each message comes from or
319
+ // it answers chat B inside chat A's train of thought. The name is cosmetic
320
+ // garnish and must never block the emit/ack hot path (the catch-up sweep is
321
+ // fully serialized — one slow lookup would stall the whole backlog): only
322
+ // the cached name is used, and a miss kicks off a fire-and-forget prefetch
323
+ // so the NEXT message from this chat gets the name. The id alone already
324
+ // marks the context switch.
325
+ const chatName = await cachedChatName(ctx, chatId);
326
+ const senderId = msg?.sender_id ? String(msg.sender_id) : undefined;
327
+ const framedText = buildChatPrompt({
328
+ chatId,
329
+ chatName,
330
+ senderId,
331
+ threadRootId,
332
+ text,
333
+ attachments,
334
+ });
335
+ // Deterministic source-derived envelope id (+ attempt suffix on re-emits):
336
+ // complete-sources strips the suffix back to the source, and every re-emit
337
+ // is globally unique so parel's dedupe (either way) can't strand a retry.
338
+ const priorEmit = await readActiveEmit(ctx, sourceType, sourceId);
339
+ const envelopeId = nextEnvelopeId(sourceType, sourceId, priorEmit);
340
+ // Input-step summary must never be empty (attachment-only messages).
341
+ const stepText = text || `[${attachments.length} attachment(s)]`;
342
+ // Thread-aware conversation identity: F2 grouping / F3 injection / fork
343
+ // routing all key on the subject, and a turn carries ONE reply route — a
344
+ // thread must never be absorbed into its sibling's turn (session.ts
345
+ // conversationKey doc).
346
+ const subject = conversationKey(chatId, threadRootId);
347
+ // Fork-on-busy scheduling (fork.ts): a live child owns its conversation;
348
+ // main mid-turn on ANOTHER conversation forks a new child; everything else
349
+ // rides main. Typed and channel_message dispatches always ride main.
350
+ const decision = await planForkDecision(ctx, subject, sourceId);
351
+ // Per-turn invocation context: parel snapshots this at turn start and
352
+ // consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
353
+ // env, so `parall` CLI calls inside the sandbox land on the right chat.
354
+ // The PRLL_DISPATCH_SOURCE_* pair is the CLI's by-source ledger binding: a
355
+ // send back into the exact trigger (chat, thread) carries dispatch_source
356
+ // so the server resolves this WorkItem in the reply's own transaction.
357
+ // (Fork turns get it too — their rows are legacy-acked so the resolve is a
358
+ // no-op today, and it wires up for free when fork ledger parity lands.)
359
+ const invocationContext = {
360
+ PRLL_CHAT_ID: chatId,
361
+ PRLL_TRIGGER_MESSAGE_ID: sourceId,
362
+ PRLL_DISPATCH_SOURCE_TYPE: sourceType,
363
+ PRLL_DISPATCH_SOURCE_ID: sourceId,
364
+ ...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
365
+ };
366
+ const envelope = {
367
+ id: envelopeId,
368
+ orgId,
369
+ connectionId: ctx.connectionId,
370
+ source: 'parall',
371
+ type: 'message',
372
+ // The binding's `main` routing sends every envelope to the agent's
373
+ // one session; subject still carries the conversation (chat, or
374
+ // chat~thread) so parel's turn input grouping (F2) and mid-turn
375
+ // injection (F3) never mix conversations in one turn, and agent
376
+ // events mirror it back for per-conversation correlation.
377
+ subject,
378
+ data: { text: framedText, chatId, messageId: sourceId, threadRootId },
379
+ // chat id + thread anchor ride replyRoute.data so deliver() knows
380
+ // where to post back (same shape channel-slack-socket uses for its
381
+ // channel id).
382
+ replyRoute: {
383
+ kind: 'provider_http',
384
+ connectionId: ctx.connectionId,
385
+ // envelopeId lets deliver() run the same staleness guard as
386
+ // onAgentEvent against the shared turnEnvelope marker.
387
+ data: { chatId, messageId: sourceId, threadRootId, envelopeId },
388
+ },
389
+ context: invocationContext,
390
+ };
391
+ if (decision.mode === 'deliverTo') {
392
+ // Follow-up for a conversation a live child owns: the envelope rides the
393
+ // full ingress contract but session resolution goes to the child (§18.2).
394
+ // Progress reporting lands on the CHILD's ase_ — its turn does the work,
395
+ // and the replyRoute carries childRef so deliver() links the reply to the
396
+ // child session (and idles IT, not main).
397
+ const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
398
+ const childAse = await ensureChildSession(ctx, decision.childRef);
399
+ if (childAse) {
400
+ const results = await Promise.all([
401
+ patchSession(ctx, childAse, { status: 'active', trigger_message_id: sourceId }),
402
+ createInputStep(ctx, childAse, {
403
+ chatId,
404
+ messageId: sourceId,
405
+ senderId,
406
+ text: stepText,
407
+ }),
408
+ ]);
409
+ // A closed child ase_ (New Session closed the whole family) just drops
410
+ // the cache; the next event rebuilds it.
411
+ if (results.includes('stale'))
412
+ await invalidateChildSession(ctx, decision.childRef);
413
+ }
414
+ await receivedSettled;
415
+ const childEnvelope = {
416
+ ...envelope,
417
+ replyRoute: {
418
+ ...envelope.replyRoute,
419
+ data: { ...envelope.replyRoute.data, childRef: decision.childRef },
420
+ },
421
+ };
422
+ // Until the child's first observed event confirms provisioning, a
423
+ // deliverTo can still land in a spawn that fails terminally (the host
424
+ // ignores its queued events) — park the WorkItem ack on the bookkeeping
425
+ // row instead of consuming it on emit. Ingress dedupe (same envelope id)
426
+ // makes the eventual replay safe. A full parking list (cap) does NOT
427
+ // degrade to ack-on-emit — that would trade bounded storage for message
428
+ // loss; the overflow stays pending un-emitted and a later replay finds
429
+ // either a confirmed child (normal ack-on-emit) or a fresh decision.
430
+ if (decision.trackAck && dispatchId) {
431
+ const parked = await trackFollowUpAck(ctx, subject, decision.childRef, {
432
+ dispatchId,
433
+ sourceId,
434
+ });
435
+ if (!parked) {
436
+ // Parking cap hit: stay pending, but arm a local retry — a healthy
437
+ // long-lived socket never re-runs the reconnect sweep, so without a
438
+ // timer the overflow follow-up would strand until the next
439
+ // disconnect. By the retry the child is usually confirmed (normal
440
+ // ack-on-emit) or gone (fresh decision).
441
+ const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
442
+ await bumpForkRetryAttempts(ctx, retryKey, {
443
+ id: dispatchId,
444
+ source_type: sourceType,
445
+ source_id: sourceId,
446
+ chat_id: chatId,
447
+ });
448
+ return asConnectorEffects([
449
+ { type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS },
450
+ ]);
451
+ }
452
+ return asConnectorEffects([
453
+ { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
454
+ ]);
455
+ }
456
+ return asConnectorEffects([
457
+ { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
458
+ ackEffect(ctx, sourceType, sourceId),
459
+ ]);
460
+ }
461
+ if (decision.mode === 'spawn') {
462
+ // Main is mid-turn on another conversation — fork. The opening message
463
+ // IS the spawn input (one turn, no idle warm-up); its dispatch is NOT
464
+ // acked here: the pending ack (by WorkItem id) rides the fork bookkeeping
465
+ // and fires on the child's first observed event, so a spawn failure
466
+ // leaves the message recoverable instead of silently lost. A REPLAY of
467
+ // this same message re-enters here with the recorded childRef (fork.ts
468
+ // branch 1a): the platform spawn is idempotent on the ref, so re-issuing
469
+ // is both the transient-failure retry and the duplicate-processing guard.
470
+ // If the child is already CONFIRMED (events observed) the replay means
471
+ // only the ACK went missing — a re-spawn is a silent no-op that emits no
472
+ // new event, so ack directly instead of waiting for one.
473
+ const replayed = await childConfirmed(ctx, subject, decision.childRef);
474
+ if (replayed && dispatchId) {
475
+ return [ackByIdEffect(ctx, dispatchId)];
476
+ }
477
+ // Verification-cap check FIRST, before any side effect: onAgentEvent is
478
+ // best-effort — if every child event is lost, a replayed spawn is a
479
+ // silent idempotent no-op that never produces a new event, so each spawn
480
+ // pass bumps a bounded counter and re-arms a timer. Once the cap is
481
+ // exceeded the ref is a black hole: clear the fork bookkeeping and FALL
482
+ // THROUGH to the main path in THIS pass (emitting yet another spawn
483
+ // would leave the message stranded with no timer and no bookkeeping
484
+ // until a reconnect).
485
+ let spawnBlackHoled = false;
486
+ const timerEffects = [];
487
+ if (dispatchId) {
488
+ const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
489
+ const attempts = await bumpForkRetryAttempts(ctx, retryKey, {
490
+ id: dispatchId,
491
+ source_type: sourceType,
492
+ source_id: sourceId,
493
+ chat_id: chatId,
494
+ });
495
+ if (attempts <= MAX_SPAWN_VERIFY_ATTEMPTS) {
496
+ timerEffects.push({ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS });
497
+ }
498
+ else {
499
+ spawnBlackHoled = true;
500
+ await clearForkSubject(ctx, subject, decision.childRef);
501
+ await ctx.store?.delete(retryKey).catch(() => { });
502
+ }
503
+ }
504
+ if (!spawnBlackHoled) {
505
+ const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
506
+ const childAse = await ensureChildSession(ctx, decision.childRef);
507
+ if (childAse) {
508
+ const results = await Promise.all([
509
+ patchSession(ctx, childAse, { status: 'active', trigger_message_id: sourceId }),
510
+ createInputStep(ctx, childAse, {
511
+ chatId,
512
+ messageId: sourceId,
513
+ senderId,
514
+ text: stepText,
515
+ }),
516
+ ]);
517
+ if (results.includes('stale'))
518
+ await invalidateChildSession(ctx, decision.childRef);
519
+ }
520
+ await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
521
+ await receivedSettled;
522
+ return asConnectorEffects([
523
+ {
524
+ type: 'spawnChildSession',
525
+ childRef: decision.childRef,
526
+ input: buildForkScopePrefix(chatId, threadRootId) + framedText,
527
+ subject,
528
+ },
529
+ ...timerEffects,
530
+ ]);
531
+ }
532
+ // fall through: black-holed spawn rides main below.
533
+ }
534
+ // Main path. Progress reporting (best-effort): received flips the row out
535
+ // of 'pending' and lights the indicator (dispatch.received → active); the
536
+ // row then STAYS 'received' for the whole turn — resolution comes from the
537
+ // CLI reply's dispatch_source or the turn event's complete-sources, never
538
+ // from an ack here (the old ack-on-emit is what killed the indicator
539
+ // early; parel-reply-contract-alignment.md §1.1). received still settles
540
+ // before this function returns so a same-batch reply can never race the
541
+ // pending→received transition. It is deliberately NOT gated on the
542
+ // session resolving: a transient session failure must not also cost the
543
+ // indicator.
140
544
  const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
141
- const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
142
545
  let sessionId = await ensureSession(ctx);
143
546
  if (sessionId) {
144
547
  // The guard marker goes down BEFORE any session write: a stale
@@ -148,6 +551,7 @@ async function effectsForDispatch(data, ctx) {
148
551
  // envelope id and idle the session out from under this message. The
149
552
  // marker is agent-global: chats share the one main session, so a chat-A
150
553
  // done signal must not idle it while this (later) message is pending.
554
+ // Child turns never touch it — only main-bound envelopes arm it.
151
555
  // (Residual read-then-patch overlap is zero when the host runs a
152
556
  // connection's hooks serially, per the C3 contract / C3b confirm item.)
153
557
  await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
@@ -156,8 +560,8 @@ async function effectsForDispatch(data, ctx) {
156
560
  createInputStep(ctx, sid, {
157
561
  chatId,
158
562
  messageId: sourceId,
159
- senderId: msg?.sender_id ? String(msg.sender_id) : undefined,
160
- text,
563
+ senderId,
564
+ text: stepText,
161
565
  }),
162
566
  ]);
163
567
  const results = await report(sessionId);
@@ -174,142 +578,63 @@ async function effectsForDispatch(data, ctx) {
174
578
  }
175
579
  }
176
580
  await receivedSettled;
177
- // Per-turn invocation context: parel snapshots this at turn start and
178
- // consume-gated plugins receive it — sandbox-e2b flattens it into per-exec
179
- // env, so `parall` CLI calls inside the sandbox land on the right chat.
180
- const invocationContext = {
181
- PRLL_CHAT_ID: chatId,
182
- PRLL_TRIGGER_MESSAGE_ID: sourceId,
183
- ...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
184
- };
185
- // Frame the message with its chat of origin: all chats share the agent's
186
- // one main session, so the model must see WHERE each message comes from or
187
- // it answers chat B inside chat A's train of thought. The name is cosmetic
188
- // garnish and must never block the emit/ack hot path (the catch-up sweep is
189
- // fully serialized — one slow lookup would stall the whole backlog): only
190
- // the cached name is used, and a miss kicks off a fire-and-forget prefetch
191
- // so the NEXT message from this chat gets the name. The id alone already
192
- // marks the context switch.
193
- const chatName = await cachedChatName(ctx, chatId);
194
- const framedText = buildChatPrompt({
195
- chatId,
196
- chatName,
197
- senderId: msg?.sender_id ? String(msg.sender_id) : undefined,
198
- threadRootId,
199
- text,
200
- });
201
- const effects = [
202
- {
203
- type: 'emitEvent',
204
- event: {
205
- id: envelopeId,
206
- orgId,
207
- connectionId: ctx.connectionId,
208
- source: 'parall',
209
- type: 'message',
210
- // The binding's `main` routing sends every envelope to the agent's
211
- // one session; subject still carries the chat so parel's turn input
212
- // grouping (F2) never mixes conversations in one turn and agent
213
- // events mirror it back for per-chat correlation.
214
- subject: chatId,
215
- data: { text: framedText, chatId, messageId: sourceId, threadRootId },
216
- // chat id + thread anchor ride replyRoute.data so deliver() knows
217
- // where to post back (same shape channel-slack-socket uses for its
218
- // channel id).
219
- replyRoute: {
220
- kind: 'provider_http',
221
- connectionId: ctx.connectionId,
222
- // envelopeId lets deliver() run the same staleness guard as
223
- // onAgentEvent against the shared turnEnvelope marker.
224
- data: { chatId, messageId: sourceId, threadRootId, envelopeId },
225
- },
226
- context: invocationContext,
227
- },
228
- },
229
- ackEffect(ctx, sourceType, sourceId),
230
- ];
231
- return effects;
581
+ // Arm the attempt fence BEFORE the emit effect is returned: turn events
582
+ // complete a source only while `active:{source}` matches their envelope id
583
+ // exactly, and the redelivery-skip window starts at this timestamp. Only
584
+ // the main path writes it — fork paths keep their own ack machinery.
585
+ // Deliberate ordering tradeoff: a crash between this write and the host
586
+ // executing the emit leaves an armed fence for an un-emitted source, so
587
+ // the reconnect sweep hides it until the TTL re-emit (≤30min delay). The
588
+ // reverse order (fence after emit) would instead double-emit on a crash —
589
+ // a duplicate agent turn. Delay heals; duplicates don't.
590
+ await writeActiveEmit(ctx, sourceType, sourceId, envelopeId);
591
+ // No ack effect: the row stays 'received' until the reply (dispatch
592
+ // _source) or the turn event (complete-sources) resolves it — the
593
+ // indicator's whole lifecycle rides on that.
594
+ return [{ type: 'emitEvent', event: envelope }];
232
595
  }
233
596
  /**
234
- * channel_message dispatch → agent (external IM channel,
235
- * external-im-channel-design.md). Same pipeline shape as the message path,
236
- * with the ChannelConversation (chv_) standing in for the chat as the
237
- * routing subject: fetch the durable ChannelMessage + its conversation,
238
- * resolve the provider for prompt labeling (cached — the connection→provider
239
- * mapping is stable), frame the prompt for the EXTERNAL audience, report
240
- * progress against the conversation-keyed session, and ack by source.
597
+ * Typed (non-message) dispatch → main-session envelope. The heavy lifting
598
+ * (entity fetch + event-body rendering + stale/pending classification) lives
599
+ * in typed-dispatch.ts; this function owns the shared emit pipeline: session
600
+ * bookkeeping, the turnEnvelope marker, the envelope, and the ack.
241
601
  *
242
- * Error contract mirrors the message path (and agent-core's
243
- * fetchAndHandleChannelMessage): 404/403 on the source entities is
244
- * permanently unusable → ack-and-drop; a transient failure keeps the
245
- * dispatch pending for the catch-up sweep.
602
+ * Typed events always ride MAIN (never fork): nobody is watching a chat
603
+ * indicator for them, so head-of-line latency costs nothing, and keeping
604
+ * them on the orchestrator preserves task/schedule context continuity.
246
605
  */
247
- async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
248
- const apiUrl = parallApiUrl(ctx);
249
- const orgId = parallOrgId(ctx);
250
- const authHeaders = { Authorization: `Bearer ${requireAgk(ctx)}` };
251
- const get = (path, timeoutMs = REQUEST_TIMEOUT_MS) => fetch(`${apiUrl}/api/v1/orgs/${orgId}${path}`, {
252
- headers: authHeaders,
253
- signal: AbortSignal.timeout(timeoutMs),
254
- });
255
- const m = await get(`/channel-messages/${sourceId}`);
256
- if (!m.ok) {
257
- if (m.status === 404 || m.status === 403) {
258
- // Deleted / inaccessible (also the shape a disabled org feature flag
259
- // takes) — permanently unusable, drop it.
260
- return [ackEffect(ctx, 'channel_message', sourceId)];
261
- }
262
- return []; // transient: keep pending, the catch-up sweep retries
263
- }
264
- const msg = (await m.json().catch(() => null));
265
- const conversationId = msg?.conversation_id ? String(msg.conversation_id) : undefined;
266
- const text = msg?.text ?? '';
267
- if (!conversationId) {
268
- // No conversation → no session key and no reply target: unusable.
269
- return [ackEffect(ctx, 'channel_message', sourceId)];
270
- }
271
- if (!text) {
272
- // The ingress adapter only persists mechanically parsed text; a row with
273
- // none will never grow any (same reasoning as attachment-only chat
274
- // messages) — drop rather than let it pile up in the pending queue.
275
- return [ackEffect(ctx, 'channel_message', sourceId)];
276
- }
277
- const c = await get(`/channel-conversations/${conversationId}`);
278
- if (!c.ok) {
279
- if (c.status === 404 || c.status === 403) {
280
- return [ackEffect(ctx, 'channel_message', sourceId)];
281
- }
606
+ async function effectsForTypedDispatch(data, ctx) {
607
+ // No markDispatchReceived here ON PURPOSE: typed dispatches have no
608
+ // message-level activity indicator (DispatchResolvedData: "typed
609
+ // dispatches have no indicator"), so there is no pending ring to light —
610
+ // the typed input step below is the session panel's timeline entry.
611
+ const sourceType = String(data.source_type ?? '');
612
+ const sourceId = String(data.source_id ?? '');
613
+ const dispatchId = String(data.id ?? '');
614
+ // Typed WorkItems ack by id, never by source: sibling rows (a task
615
+ // mutation's task_assign + task_update) share (source_type, source_id),
616
+ // and the source ack would terminally resolve work that was never emitted.
617
+ const ack = () => dispatchId ? ackByIdEffect(ctx, dispatchId) : ackEffect(ctx, sourceType, sourceId);
618
+ const plan = await planTypedDispatch(data, ctx);
619
+ if (plan.outcome === 'pending')
282
620
  return [];
283
- }
284
- const conv = (await c.json().catch(() => null));
285
- // Provider (= clip alias) for prompt labeling + the reply hint.
286
- // Best-effort: an unresolved provider degrades the wording, never blocks.
287
- const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
288
- const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
289
- const senderName = msg?.external_user_name || msg?.external_user_id || 'external user';
290
- const externalConversationId = conv?.external_conversation_id
291
- ? String(conv.external_conversation_id)
292
- : undefined;
293
- const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
294
- // Progress reporting — identical ordering contract to the message path
295
- // (see the comments there): received settles before the ack effect is
296
- // returned; the turnEnvelope marker goes down before any session write.
297
- const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
298
- const envelopeId = dispatchId || `channel_message:${sourceId}`;
621
+ if (plan.outcome === 'stale')
622
+ return [ack()];
623
+ const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
299
624
  let sessionId = await ensureSession(ctx);
300
625
  if (sessionId) {
626
+ // Same marker contract as the message path: a typed envelope is now the
627
+ // agent's latest emitted input, so older turns' done signals must not
628
+ // idle the session out from under it.
301
629
  await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
302
630
  const report = (sid) => Promise.all([
303
- patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
304
- createChannelInputStep(ctx, sid, {
305
- conversationId,
306
- channelMessageId: sourceId,
307
- provider,
308
- externalConversationId,
309
- senderId: msg?.external_user_id ? String(msg.external_user_id) : 'external',
310
- senderName,
311
- text,
312
- sentAt: msg?.received_at ? String(msg.received_at) : undefined,
631
+ patchSession(ctx, sid, { status: 'active' }),
632
+ createTypedInputStep(ctx, sid, {
633
+ target: plan.stepTarget,
634
+ triggerType: plan.triggerType,
635
+ dispatchId,
636
+ sourceId,
637
+ summary: plan.summary,
313
638
  }),
314
639
  ]);
315
640
  const results = await report(sessionId);
@@ -321,72 +646,46 @@ async function effectsForChannelDispatch(sourceId, dispatchId, ctx) {
321
646
  sessionId = fresh;
322
647
  }
323
648
  }
324
- // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
325
- // conversation into its session; last-write-wins on the server). Only on
326
- // change — the store remembers what was last pushed — and fully
327
- // fire-and-forget: pure bookkeeping must never add latency to the
328
- // emit/ack path (a lost attempt is retried on the next message because
329
- // the store memo only updates on success).
330
- void recordConversationSession(ctx, conversationId, sessionId).catch(() => { });
331
649
  }
332
- await receivedSettled;
333
650
  return [
334
651
  {
335
652
  type: 'emitEvent',
336
653
  event: {
337
654
  id: envelopeId,
338
- orgId,
655
+ orgId: parallOrgId(ctx),
339
656
  connectionId: ctx.connectionId,
340
657
  source: 'parall',
341
- // The host's message intake path turns data.text into the user
342
- // message — the external-audience framing rides inside it (parel has
343
- // no separate prompt channel), so 'message' is the right type here.
344
658
  type: 'message',
345
- // main routing sends this to the agent's one session too; subject
346
- // still carries the external conversation (chv_) for parel's turn
347
- // input grouping (F2) and per-conversation event correlation.
348
- subject: conversationId,
349
- data: {
350
- text: buildChannelPrompt({
351
- provider,
352
- conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
353
- externalConversationId,
354
- externalMessageId,
355
- senderName,
356
- text,
357
- }),
358
- conversationId,
359
- channelMessageId: sourceId,
360
- provider,
361
- externalConversationId,
362
- externalMessageId,
363
- },
364
- // No chatId on purpose: the reply goes out through the provider clip
365
- // (agent-invoked), NOT deliver() — deliver only runs the redundant
366
- // session idle for channel routes (see delivery.ts).
659
+ // Subject = the source entity (task/schedule/trigger/…): F2 groups
660
+ // consecutive events for one entity into one turn and never mixes
661
+ // them with chat conversations.
662
+ subject: plan.subject,
663
+ data: { text: plan.body, sourceType, sourceId },
367
664
  replyRoute: {
368
665
  kind: 'provider_http',
369
666
  connectionId: ctx.connectionId,
370
- data: { conversationId, envelopeId },
667
+ // Replies are CLI-only for every typed event (nothing
668
+ // auto-delivers). chatId — only approval_decided has one — merely
669
+ // routes deliver()'s suppressed turn-end audit step to the chat;
670
+ // chat-less routes carry just the envelopeId for the redundant
671
+ // ledger close + idle.
672
+ data: plan.chatId
673
+ ? { chatId: plan.chatId, messageId: sourceId, envelopeId }
674
+ : { envelopeId },
371
675
  },
372
- // No invocation context: PRLL_CHAT_ID must not point CLI defaults at
373
- // a chat that doesn't exist, and the reply targets (external ids)
374
- // already ride the prompt verbatim.
676
+ ...(plan.chatId
677
+ ? {
678
+ context: {
679
+ PRLL_CHAT_ID: plan.chatId,
680
+ PRLL_TRIGGER_MESSAGE_ID: sourceId,
681
+ },
682
+ }
683
+ : {}),
375
684
  },
376
685
  },
377
- ackEffect(ctx, 'channel_message', sourceId),
686
+ ack(),
378
687
  ];
379
688
  }
380
- /**
381
- * connection id → provider alias, cached in the durable store (stable
382
- * mapping; avoids one connection fetch per inbound message). Cache hits are
383
- * a store read; the miss-path fetch is cosmetic (prompt labeling only), so
384
- * it gets a tight timeout — same 3s budget as deliver's front-of-reply
385
- * session lookup — rather than holding the message's emit/ack behind the
386
- * full REQUEST_TIMEOUT_MS. Any failure returns undefined — prompt wording
387
- * degrades to neutral rather than inventing a clip alias.
388
- */
389
- const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
390
689
  /**
391
690
  * How long a cached chat name is served without revalidation. Chat renames
392
691
  * are rare and the frame is the model's cross-chat context anchor, so serve
@@ -445,51 +744,6 @@ async function prefetchChatName(ctx, chatId, cacheKey) {
445
744
  // Cosmetic prefetch — the next message from this chat retries.
446
745
  }
447
746
  }
448
- async function resolveChannelProvider(ctx, connectionId, get) {
449
- const cacheKey = `channelProvider:${connectionId}`;
450
- const cached = await ctx.store?.get(cacheKey).catch(() => null);
451
- if (typeof cached === 'string' && cached)
452
- return cached;
453
- try {
454
- const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
455
- if (!res.ok)
456
- return undefined;
457
- const body = (await res.json().catch(() => null));
458
- const provider = body?.provider ? String(body.provider) : undefined;
459
- if (provider)
460
- await ctx.store?.set(cacheKey, provider).catch(() => { });
461
- return provider;
462
- }
463
- catch {
464
- return undefined;
465
- }
466
- }
467
- /** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
468
- async function recordConversationSession(ctx, conversationId, sessionId) {
469
- const mappedKey = `convSession:${conversationId}`;
470
- const mapped = await ctx.store?.get(mappedKey).catch(() => null);
471
- if (mapped === sessionId)
472
- return;
473
- try {
474
- const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`, {
475
- method: 'PATCH',
476
- headers: {
477
- Authorization: `Bearer ${requireAgk(ctx)}`,
478
- 'Content-Type': 'application/json',
479
- },
480
- body: JSON.stringify({ agent_session_id: sessionId }),
481
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
482
- });
483
- if (!res.ok) {
484
- console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
485
- return;
486
- }
487
- await ctx.store?.set(mappedKey, sessionId).catch(() => { });
488
- }
489
- catch (err) {
490
- console.warn('[parel-channel] conversation session mapping failed', err);
491
- }
492
- }
493
747
  /**
494
748
  * Agent execution events (parel E1+E2, plugin-sdk 0.9.0, opt-in via the
495
749
  * channel declaration's `observe: [turn, steps]`).
@@ -507,18 +761,86 @@ async function recordConversationSession(ctx, conversationId, sessionId) {
507
761
  * done event just means the ring clears on the next turn's trigger change; a
508
762
  * missed trace step is a gap in the panel, nothing more.
509
763
  */
510
- export async function handleParallAgentEvent(event, ctx) {
764
+ export function handleParallAgentEvent(event, ctx) {
765
+ return serialized(() => handleParallAgentEventInner(event, ctx));
766
+ }
767
+ async function handleParallAgentEventInner(event, ctx) {
768
+ // spawnChildSession's asynchronous error channel — not turn-scoped, pushed
769
+ // regardless of observe scopes, and shaped outside the published AgentEvent
770
+ // union (see parel-sdk-compat). Handle it before anything subject-gated.
771
+ const spawnFailed = asChildSpawnFailed(event);
772
+ if (spawnFailed) {
773
+ const effects = [];
774
+ try {
775
+ const outcome = await handleChildSpawnFailed(ctx, spawnFailed);
776
+ if (outcome.cleared) {
777
+ // The spawn's ase_ was already created active with an input step; no
778
+ // child turn will ever emit a terminal event for it — settle it here
779
+ // or the session (and its processing indicator) stays active forever.
780
+ const orphanAse = await ensureChildSession(ctx, spawnFailed.childRef);
781
+ if (orphanAse)
782
+ await patchSession(ctx, orphanAse, { status: 'idle' });
783
+ await invalidateChildSession(ctx, spawnFailed.childRef);
784
+ }
785
+ // Re-drive the stranded messages WITHOUT waiting for a reconnect sweep
786
+ // (a healthy long-lived socket never re-runs onOpen): park their
787
+ // coordinates and arm a timer — onTimer re-plans them through the
788
+ // normal dispatch pipeline (transient failures re-enter branch 1a with
789
+ // the same ref; terminal ones re-plan from scratch).
790
+ if (outcome.subject && outcome.pendingAcks.length > 0) {
791
+ const chatId = outcome.subject.split('~')[0];
792
+ for (const ack of outcome.pendingAcks) {
793
+ const key = `${FORK_RETRY_PREFIX}${ack.dispatchId}`;
794
+ await bumpForkRetryAttempts(ctx, key, {
795
+ id: ack.dispatchId,
796
+ source_type: 'message',
797
+ source_id: ack.sourceId,
798
+ chat_id: chatId,
799
+ });
800
+ effects.push({ type: 'setTimer', key, at: ctx.now() + FORK_RETRY_DELAY_MS });
801
+ }
802
+ }
803
+ }
804
+ catch (err) {
805
+ console.error('[parel-channel] child spawn failure handling failed', err);
806
+ }
807
+ return effects;
808
+ }
809
+ const withChild = event;
810
+ if (withChild.childRef) {
811
+ return handleChildAgentEvent(withChild, ctx);
812
+ }
511
813
  const chatId = event.subject;
512
- if (!chatId)
513
- return [];
514
814
  try {
815
+ // Best-effort busy classifier for fork-on-busy: only MAIN turns feed it
816
+ // (child events took the branch above).
817
+ await noteMainTurnEvent(ctx, withChild).catch(() => { });
818
+ if (!chatId)
819
+ return [];
515
820
  if (event.type === 'turn_completed' || event.type === 'turn_failed') {
821
+ // Ledger close FIRST, fenced per source — deliberately NOT behind the
822
+ // agent-global marker guard below: that guard protects the shared
823
+ // session's idle (last-emit-wins), while the ledger rows are
824
+ // per-source facts that must close whenever THEIR envelope is the one
825
+ // this turn consumed (a concurrent conversation-B emit must not leave
826
+ // conversation-A's resolution hanging). The attempt fence inside
827
+ // completeFencedEnvelopes drops late events from a superseded attempt
828
+ // (the TTL re-emit took the source over) AND fork-handled envelopes
829
+ // (no fence written), so it can never close the in-flight retry's
830
+ // work nor double-close fork acks. F3-absorbed envelopes appear in
831
+ // the absorbing turn's envelopeIds and settle here naturally. The
832
+ // server decides reply broad-cover vs no_action per batch; turn_failed
833
+ // sweeps the same way with the error step below as the visible record.
834
+ await completeFencedEnvelopes(ctx, event.envelopeIds);
516
835
  // Staleness guard: only idle if this event's turn covers the AGENT'S
517
836
  // latest emitted envelope (the marker is agent-global — all chats share
518
837
  // the one main session, so a done signal for chat A's turn must not
519
838
  // clear the indicator while a later chat-B message is queued/running).
520
- // A late/retried event for a superseded turn is dropped. Fail-open
521
- // when the marker is unavailable (a stuck ring is worse than a blink).
839
+ // With injectInFlight (parel F3), an absorbed envelope has no turn of
840
+ // its own — its id is appended to the absorbing turn's envelopeIds, so
841
+ // this membership check settles it naturally. A late/retried event for
842
+ // a superseded turn is dropped. Fail-open when the marker is
843
+ // unavailable (a stuck ring is worse than a blink).
522
844
  const current = await ctx.store?.get(TURN_ENVELOPE_KEY).catch(() => null);
523
845
  if (typeof current === 'string' && current && !event.envelopeIds.includes(current)) {
524
846
  return [];
@@ -558,28 +880,73 @@ export async function handleParallAgentEvent(event, ctx) {
558
880
  }
559
881
  return [];
560
882
  }
561
- // turn_started (processing is input-step-driven) and execution_paused
562
- // (E3 — no resolve loop wired yet, HITL deliberately deferred) are
563
- // ignored; observe only requests [turn, steps].
883
+ // turn_started feeds only the busy classifier above (processing is
884
+ // input-step-driven); execution_paused (E3 — no resolve loop wired yet,
885
+ // HITL deliberately deferred) is ignored.
564
886
  }
565
887
  catch (err) {
566
888
  console.error('[parel-channel] agent event handling failed', err);
567
889
  }
568
890
  return [];
569
891
  }
570
- /** Ack a dispatch by source so it leaves the pending queue (idempotent). */
571
- function ackEffect(ctx, sourceType, sourceId) {
572
- return {
573
- type: 'fetch',
574
- request: {
575
- url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/ack`,
576
- method: 'POST',
577
- headers: {
578
- Authorization: `Bearer ${requireAgk(ctx)}`,
579
- 'Content-Type': 'application/json',
580
- },
581
- body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
582
- },
583
- idempotencyKey: `ack:${sourceType}:${sourceId}`,
584
- };
892
+ /**
893
+ * Agent events from a connector-spawned fork child (they carry childRef).
894
+ * Mirrors the main-session handling against the CHILD's ase_ instead:
895
+ * turn done → idle the child session (its indicator/panel), turn_failed →
896
+ * error step first, E2 → trace steps. Additionally settles the fork's
897
+ * pending spawn ack: the opening dispatch is acked on the child's first
898
+ * observed event (a spawn that never produces one leaves it pending for the
899
+ * catch-up sweep — see fork.ts). Child turns never touch the main session's
900
+ * turnEnvelope marker or the busy classifier.
901
+ */
902
+ async function handleChildAgentEvent(event, ctx) {
903
+ const childRef = event.childRef ?? '';
904
+ const effects = [];
905
+ try {
906
+ const settled = await settleChildEvent(ctx, event).catch(() => null);
907
+ for (const ack of settled?.pendingAcks ?? []) {
908
+ // Ack the fork's parked WorkItems (by id) — the child demonstrably
909
+ // exists and holds them. Re-sent on later events (bounded) because the
910
+ // fetch effect is fire-and-forget; the ack endpoint is idempotent.
911
+ effects.push(ackByIdEffect(ctx, ack.dispatchId));
912
+ }
913
+ const subject = event.subject || settled?.subject || '';
914
+ if (!subject)
915
+ return effects;
916
+ // A childRef outside live bookkeeping is a LATE callback (post-reset or
917
+ // post-give-up): resolving a session here would blind-recreate a fork
918
+ // ase_ under the new conversation line — drop the event instead.
919
+ if (!(await childRefKnown(ctx, childRef)))
920
+ return effects;
921
+ const sessionId = await ensureChildSession(ctx, childRef);
922
+ if (!sessionId)
923
+ return effects;
924
+ if (event.type === 'turn_completed' || event.type === 'turn_failed') {
925
+ let stale = false;
926
+ if (event.type === 'turn_failed') {
927
+ const stepResult = await createErrorStep(ctx, sessionId, {
928
+ subject,
929
+ turnId: event.turnId,
930
+ error: event.error,
931
+ });
932
+ stale = stepResult === 'stale';
933
+ }
934
+ const result = await patchSession(ctx, sessionId, { status: 'idle' });
935
+ if (stale || result === 'stale')
936
+ await invalidateChildSession(ctx, childRef);
937
+ return effects;
938
+ }
939
+ if (event.type === 'model_reasoning' ||
940
+ event.type === 'tool_call' ||
941
+ event.type === 'tool_result') {
942
+ const result = await createTraceStep(ctx, sessionId, subject, event);
943
+ if (result === 'stale')
944
+ await invalidateChildSession(ctx, childRef);
945
+ return effects;
946
+ }
947
+ }
948
+ catch (err) {
949
+ console.error('[parel-channel] child agent event handling failed', err);
950
+ }
951
+ return effects;
585
952
  }