@parall/parel-channel 1.43.0 → 1.45.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/delivery.js CHANGED
@@ -77,7 +77,7 @@ export async function buildParallDelivery(delivery, ctx) {
77
77
  // ON and observe OFF — the platform template always sets both together,
78
78
  // so it exists only on a hand-crafted binding.
79
79
  if (route.envelopeId) {
80
- await completeFencedEnvelopes(ctx, [route.envelopeId]);
80
+ await completeFencedEnvelopes(ctx, [route.envelopeId], 'ok');
81
81
  }
82
82
  const subject = route.chatId ?? route.conversationId;
83
83
  if (subject) {
@@ -1,7 +1,7 @@
1
1
  import { channelCapabilityEnv } from './channel-capability.js';
2
2
  import { buildChannelPrompt } from './channel-prompt.js';
3
3
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
4
- import { ackEffect, createChannelInputStep, ensureSession, invalidateSession, markDispatchReceived, nextEnvelopeId, patchSession, readActiveEmit, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
4
+ import { claimDispatchSource, completeSourceEffect, createChannelInputStep, ensureSession, invalidateSession, nextEnvelopeId, patchSession, readActiveEmit, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
5
5
  /**
6
6
  * External IM inbound (channel_message dispatches — Feishu/Slack via the
7
7
  * platform-mediated channel, external-im-channel-design.md). Split from
@@ -41,7 +41,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
41
41
  if (m.status === 404 || m.status === 403) {
42
42
  // Deleted / inaccessible (also the shape a disabled org feature flag
43
43
  // takes) — permanently unusable, drop it.
44
- return [ackEffect(ctx, 'channel_message', sourceId)];
44
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
45
45
  }
46
46
  return []; // transient: keep pending, the catch-up sweep retries
47
47
  }
@@ -50,18 +50,18 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
50
50
  const text = msg?.text ?? '';
51
51
  if (!conversationId) {
52
52
  // No conversation → no session key and no reply target: unusable.
53
- return [ackEffect(ctx, 'channel_message', sourceId)];
53
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
54
54
  }
55
55
  if (!text) {
56
56
  // The ingress adapter only persists mechanically parsed text; a row with
57
57
  // none will never grow any (same reasoning as attachment-only chat
58
58
  // messages) — drop rather than let it pile up in the pending queue.
59
- return [ackEffect(ctx, 'channel_message', sourceId)];
59
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
60
60
  }
61
61
  const c = await get(`/channel-conversations/${conversationId}`);
62
62
  if (!c.ok) {
63
63
  if (c.status === 404 || c.status === 403) {
64
- return [ackEffect(ctx, 'channel_message', sourceId)];
64
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
65
65
  }
66
66
  return [];
67
67
  }
@@ -79,7 +79,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
79
79
  // (see the comments there): received settles before this function returns;
80
80
  // the turnEnvelope marker goes down before any session write; the row
81
81
  // stays 'received' until the turn event's complete-sources.
82
- const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
82
+ const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
83
83
  const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
84
84
  const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
85
85
  let sessionId = await ensureSession(ctx);
@@ -1 +1 @@
1
- {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AAkH3B;kDACkD;AAClD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CASjE;AACD,wBAAgB,UAAU,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAO9D;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAG5B;AAwCD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,EACtB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAE5B;AA8BD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAE/E;AAsqBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAE7B"}
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../src/inbound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,cAAc,EACf,MAAM,mBAAmB,CAAC;AAkH3B;kDACkD;AAClD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CASjE;AACD,wBAAgB,UAAU,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAO9D;AAED,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAG5B;AAwCD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,EACtB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAE5B;AA8BD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAE/E;AAkrBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAE7B"}
package/dist/inbound.js CHANGED
@@ -3,7 +3,7 @@ import { buildChatPrompt } from './channel-prompt.js';
3
3
  import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
4
4
  import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
5
5
  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';
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';
6
+ import { ackByIdEffect, claimDispatchSource, completeFencedEnvelopes, completeSourceEffect, conversationKey, createErrorStep, fetchMainSessionFresh, createInputStep, createTraceStep, createTypedInputStep, EMIT_TTL_MS, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, nextEnvelopeId, patchSession, readActiveEmit, reportingEnabled, SESSION_CACHE_KEY, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
7
7
  import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
8
8
  /**
9
9
  * Connection-local hook serialization. The parel ConnectorHost dispatches
@@ -274,7 +274,7 @@ async function effectsForDispatch(data, ctx) {
274
274
  return [];
275
275
  if (!chatId) {
276
276
  // A message dispatch with no chat can never be routed or replied to.
277
- return [ackEffect(ctx, sourceType, sourceId)];
277
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
278
278
  }
279
279
  if (await shouldSkipLiveRow(ctx, sourceType, sourceId))
280
280
  return [];
@@ -289,7 +289,7 @@ async function effectsForDispatch(data, ctx) {
289
289
  if (!m.ok) {
290
290
  if (m.status === 404 || m.status === 403) {
291
291
  // Deleted message / lost chat access — permanently unusable, drop it.
292
- return [ackEffect(ctx, sourceType, sourceId)];
292
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
293
293
  }
294
294
  // Transient failure: keep pending, the catch-up sweep retries.
295
295
  return [];
@@ -312,7 +312,7 @@ async function effectsForDispatch(data, ctx) {
312
312
  // Truly empty messages never grow content — ack-and-drop rather than let
313
313
  // them pile up in the pending queue forever. (Attachment-only messages
314
314
  // used to take this path; they now ride the attachment lines instead.)
315
- return [ackEffect(ctx, sourceType, sourceId)];
315
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
316
316
  }
317
317
  // Frame the message with its chat of origin: all chats share the agent's
318
318
  // one main session, so the model must see WHERE each message comes from or
@@ -400,7 +400,7 @@ async function effectsForDispatch(data, ctx) {
400
400
  // Progress reporting lands on the CHILD's ase_ — its turn does the work,
401
401
  // and the replyRoute carries childRef so deliver() links the reply to the
402
402
  // child session (and idles IT, not main).
403
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
403
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
404
404
  const childAse = await ensureChildSession(ctx, decision.childRef);
405
405
  if (childAse) {
406
406
  const results = await Promise.all([
@@ -459,7 +459,9 @@ async function effectsForDispatch(data, ctx) {
459
459
  }
460
460
  return [
461
461
  { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
462
- ackEffect(ctx, sourceType, sourceId),
462
+ // Fork-handled rows keep the legacy ack machinery (ledger parity is a
463
+ // declared follow-up); by id — the precise form.
464
+ dispatchId ? ackByIdEffect(ctx, dispatchId) : completeSourceEffect(ctx, sourceType, sourceId),
463
465
  ];
464
466
  }
465
467
  if (decision.mode === 'spawn') {
@@ -506,7 +508,7 @@ async function effectsForDispatch(data, ctx) {
506
508
  }
507
509
  }
508
510
  if (!spawnBlackHoled) {
509
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
511
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
510
512
  const childAse = await ensureChildSession(ctx, decision.childRef);
511
513
  if (childAse) {
512
514
  const results = await Promise.all([
@@ -552,7 +554,7 @@ async function effectsForDispatch(data, ctx) {
552
554
  // pending→received transition. It is deliberately NOT gated on the
553
555
  // session resolving: a transient session failure must not also cost the
554
556
  // indicator.
555
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
557
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
556
558
  let sessionId = await ensureSession(ctx);
557
559
  if (sessionId) {
558
560
  // The guard marker goes down BEFORE any session write: a stale
@@ -615,7 +617,7 @@ async function effectsForDispatch(data, ctx) {
615
617
  * them on the orchestrator preserves task/schedule context continuity.
616
618
  */
617
619
  async function effectsForTypedDispatch(data, ctx) {
618
- // No markDispatchReceived here ON PURPOSE: typed dispatches have no
620
+ // No claimDispatchSource here ON PURPOSE: typed dispatches have no
619
621
  // message-level activity indicator (DispatchResolvedData: "typed
620
622
  // dispatches have no indicator"), so there is no pending ring to light —
621
623
  // the typed input step below is the session panel's timeline entry.
@@ -625,12 +627,21 @@ async function effectsForTypedDispatch(data, ctx) {
625
627
  // Typed WorkItems ack by id, never by source: sibling rows (a task
626
628
  // mutation's task_assign + task_update) share (source_type, source_id),
627
629
  // and the source ack would terminally resolve work that was never emitted.
628
- const ack = () => dispatchId ? ackByIdEffect(ctx, dispatchId) : ackEffect(ctx, sourceType, sourceId);
630
+ const ack = () => {
631
+ if (!dispatchId) {
632
+ // No WorkItem id (malformed payload): the by-source form would
633
+ // terminally resolve SIBLING typed rows sharing this source — leave
634
+ // the row pending for the sweep instead.
635
+ console.error(`[parel-channel] typed dispatch without id (${sourceType}:${sourceId}) left pending`);
636
+ return [];
637
+ }
638
+ return [ackByIdEffect(ctx, dispatchId)];
639
+ };
629
640
  const plan = await planTypedDispatch(data, ctx);
630
641
  if (plan.outcome === 'pending')
631
642
  return [];
632
643
  if (plan.outcome === 'stale')
633
- return [ack()];
644
+ return ack();
634
645
  const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
635
646
  let sessionId = await ensureSession(ctx);
636
647
  if (sessionId) {
@@ -696,7 +707,7 @@ async function effectsForTypedDispatch(data, ctx) {
696
707
  ...(Object.keys(typedContext).length > 0 ? { context: typedContext } : {}),
697
708
  },
698
709
  },
699
- ack(),
710
+ ...ack(),
700
711
  ];
701
712
  }
702
713
  /**
@@ -842,7 +853,7 @@ async function handleParallAgentEventInner(event, ctx) {
842
853
  // the absorbing turn's envelopeIds and settle here naturally. The
843
854
  // server decides reply broad-cover vs no_action per batch; turn_failed
844
855
  // sweeps the same way with the error step below as the visible record.
845
- await completeFencedEnvelopes(ctx, event.envelopeIds);
856
+ await completeFencedEnvelopes(ctx, event.envelopeIds, event.type === 'turn_failed' ? 'error' : 'ok');
846
857
  // Staleness guard: only idle if this event's turn covers the AGENT'S
847
858
  // latest emitted envelope (the marker is agent-global — all chats share
848
859
  // the one main session, so a done signal for chat A's turn must not
package/dist/session.d.ts CHANGED
@@ -216,12 +216,14 @@ export declare function createTraceStep(ctx: ConnectorContext, sessionId: string
216
216
  outputPreview?: string;
217
217
  }): Promise<ReportResult>;
218
218
  /**
219
- * Ack a dispatch by source so it leaves the pending queue (idempotent).
220
- * Typed as the fetch effect so it can ride BOTH hook channels (onMessage's
221
- * ConnectorEffect[] and onAgentEvent's AgentEventEffect[] — the fork spawn
222
- * ack fires from a child agent event).
219
+ * Terminally drop ONE source's live WorkItem via the unified complete's
220
+ * source form (no covering reply Effect exists for a dropped row, so the
221
+ * server sweeps it to no_action — the drop semantics; idempotent). Typed as
222
+ * the fetch effect so it can ride BOTH hook channels (ConnectorEffect[] and
223
+ * AgentEventEffect[]). Replaces the legacy by-source ack for drop cases —
224
+ * that endpoint retires at S3 (dispatch-convergence-design.md §3).
223
225
  */
224
- export declare function ackEffect(ctx: ConnectorContext, sourceType: string, sourceId: string): Extract<ConnectorEffect, {
226
+ export declare function completeSourceEffect(ctx: ConnectorContext, sourceType: string, sourceId: string): Extract<ConnectorEffect, {
225
227
  type: 'fetch';
226
228
  }>;
227
229
  /**
@@ -236,12 +238,16 @@ export declare function ackByIdEffect(ctx: ConnectorContext, dispatchId: string)
236
238
  type: 'fetch';
237
239
  }>;
238
240
  /**
239
- * Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
240
- * before the ack effect is returned: MarkReceived only transitions rows still
241
- * in status='pending', and ack moves the same row to 'acked' losing the race
242
- * means the ring never appears.
241
+ * Claim the source's WorkItem before the turn (server folds the row into its
242
+ * message lane, flips it pending→received and broadcasts dispatch.received
243
+ * the pending ring rides the claim side effect now; the retired received
244
+ * endpoint was a bare receipt). Best-effort like the receipt was: a refused
245
+ * or failed claim never blocks the emit — the row stays pending and the
246
+ * turn's complete resolves it just the same. Must COMPLETE before any
247
+ * settling effect is returned so a same-batch complete can't race the
248
+ * pending→received transition.
243
249
  */
244
- export declare function markDispatchReceived(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<void>;
250
+ export declare function claimDispatchSource(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<void>;
245
251
  /**
246
252
  * How long an emitted source may stay unresolved before the reconnect sweep
247
253
  * re-emits it (crash / lost turn_completed self-heal — the successor of the
@@ -263,6 +269,14 @@ export type ActiveEmit = {
263
269
  export declare function readActiveEmit(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<ActiveEmit | null>;
264
270
  export declare function writeActiveEmit(ctx: ConnectorContext, sourceType: string, sourceId: string, envelopeId: string): Promise<void>;
265
271
  export declare function clearActiveEmit(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<void>;
272
+ /**
273
+ * Burn the fence after an ERROR close: keep the attempt id (so the re-driven
274
+ * emit's nextEnvelopeId picks a fresh `#r{n+1}` — a bare repeat of the base
275
+ * id could be deduped by parel and strand the retry) but zero the timestamp
276
+ * (so the redelivery-skip window is already over and the server's re-drive
277
+ * hint re-emits immediately instead of hiding behind a 30-min TTL).
278
+ */
279
+ export declare function burnActiveEmit(ctx: ConnectorContext, sourceType: string, sourceId: string): Promise<void>;
266
280
  /**
267
281
  * Deterministic envelope id for a source: `{sourceType}:{sourceId}` on the
268
282
  * first attempt, `…#r{n}` on re-emits. Attempt-suffixed ids are unique per
@@ -275,6 +289,8 @@ export declare function sourceFromEnvelopeId(envelopeId: string): {
275
289
  sourceType: string;
276
290
  sourceId: string;
277
291
  } | null;
292
+ /** Turn outcome for the ledger close — mirrors the server's turn_outcome. */
293
+ export type TurnOutcome = 'ok' | 'error';
278
294
  /**
279
295
  * Fence-checked turn-end ledger close for a set of envelope ids — the shared
280
296
  * core of BOTH turn-end hooks: onAgentEvent (turn_completed/turn_failed, the
@@ -282,21 +298,29 @@ export declare function sourceFromEnvelopeId(envelopeId: string): {
282
298
  * never arrive and this is the ONLY terminal ledger hook — without it a
283
299
  * received row would re-emit on every TTL sweep forever). Only sources whose
284
300
  * `active:{source}` store row matches the envelope id EXACTLY are completed
285
- * (attempt fence); the fence rows clear on success. Both hooks running is
286
- * harmless: whichever completes first consumes the fence, the other skips.
301
+ * (attempt fence). Both hooks running is harmless: whichever completes first
302
+ * consumes the fence, the other skips.
303
+ *
304
+ * Fence disposal follows the outcome: an "ok" close clears the fences (the
305
+ * sources are terminal); an "error" close BURNS them instead — the server
306
+ * released the uncovered members back to pending and its re-drive hint
307
+ * re-emits them, so the burned fence must neither hide the retry behind the
308
+ * redelivery-skip TTL nor let it reuse a dedupe-prone envelope id.
287
309
  */
288
- export declare function completeFencedEnvelopes(ctx: ConnectorContext, envelopeIds: string[]): Promise<void>;
310
+ export declare function completeFencedEnvelopes(ctx: ConnectorContext, envelopeIds: string[], outcome?: TurnOutcome): Promise<void>;
289
311
  /**
290
- * Turn-end ledger close: resolves the turn's folded sources on the server
291
- * broad-cover to the turn's reply Effect when one exists, no_action sweep
292
- * otherwise (the server decides; POST /dispatch/complete-sources, idempotent).
293
- * One immediate retry on failure; a still-failed call leaves the rows
294
- * received and the catch-up TTL re-emit self-heals.
312
+ * Turn-end ledger close: resolves the turn's folded sources on the server via
313
+ * the unified complete's source form (POST /dispatch/complete, idempotent)
314
+ * broad-cover to the turn's reply Effect when one exists; otherwise a
315
+ * no_action sweep on "ok", or a release back to pending on the redrive budget
316
+ * on "error" (a failed turn must not silently consume work it never handled).
317
+ * One immediate retry on failure; a still-failed call leaves the rows live
318
+ * and the catch-up TTL re-emit self-heals.
295
319
  */
296
320
  export declare function completeSources(ctx: ConnectorContext, sources: {
297
321
  source_type: string;
298
322
  source_id: string;
299
- }[]): Promise<boolean>;
323
+ }[], outcome?: TurnOutcome): Promise<boolean>;
300
324
  /**
301
325
  * Turn-end plain text as a suppressed audit step — the runtime-standard
302
326
  * Layer 0 posture (agent-dm-loop-prevention.md): recorded on the session,
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/dist/session.js CHANGED
@@ -523,24 +523,26 @@ export async function createTraceStep(ctx, sessionId, subject, event) {
523
523
  }
524
524
  }
525
525
  /**
526
- * Ack a dispatch by source so it leaves the pending queue (idempotent).
527
- * Typed as the fetch effect so it can ride BOTH hook channels (onMessage's
528
- * ConnectorEffect[] and onAgentEvent's AgentEventEffect[] — the fork spawn
529
- * ack fires from a child agent event).
526
+ * Terminally drop ONE source's live WorkItem via the unified complete's
527
+ * source form (no covering reply Effect exists for a dropped row, so the
528
+ * server sweeps it to no_action — the drop semantics; idempotent). Typed as
529
+ * the fetch effect so it can ride BOTH hook channels (ConnectorEffect[] and
530
+ * AgentEventEffect[]). Replaces the legacy by-source ack for drop cases —
531
+ * that endpoint retires at S3 (dispatch-convergence-design.md §3).
530
532
  */
531
- export function ackEffect(ctx, sourceType, sourceId) {
533
+ export function completeSourceEffect(ctx, sourceType, sourceId) {
532
534
  return {
533
535
  type: 'fetch',
534
536
  request: {
535
- url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/ack`,
537
+ url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete`,
536
538
  method: 'POST',
537
539
  headers: {
538
540
  Authorization: `Bearer ${requireAgk(ctx)}`,
539
541
  'Content-Type': 'application/json',
540
542
  },
541
- body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
543
+ body: JSON.stringify({ sources: [{ source_type: sourceType, source_id: sourceId }] }),
542
544
  },
543
- idempotencyKey: `ack:${sourceType}:${sourceId}`,
545
+ idempotencyKey: `complete:src:${sourceType}:${sourceId}`,
544
546
  };
545
547
  }
546
548
  /**
@@ -567,18 +569,22 @@ export function ackByIdEffect(ctx, dispatchId) {
567
569
  };
568
570
  }
569
571
  /**
570
- * Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
571
- * before the ack effect is returned: MarkReceived only transitions rows still
572
- * in status='pending', and ack moves the same row to 'acked' losing the race
573
- * means the ring never appears.
572
+ * Claim the source's WorkItem before the turn (server folds the row into its
573
+ * message lane, flips it pending→received and broadcasts dispatch.received
574
+ * the pending ring rides the claim side effect now; the retired received
575
+ * endpoint was a bare receipt). Best-effort like the receipt was: a refused
576
+ * or failed claim never blocks the emit — the row stays pending and the
577
+ * turn's complete resolves it just the same. Must COMPLETE before any
578
+ * settling effect is returned so a same-batch complete can't race the
579
+ * pending→received transition.
574
580
  */
575
- export async function markDispatchReceived(ctx, sourceType, sourceId) {
581
+ export async function claimDispatchSource(ctx, sourceType, sourceId) {
576
582
  // Deliberately NOT gated on reportingEnabled: this is ledger surface, not
577
- // session reporting — the URL needs no agent id, and with ack-on-emit gone
578
- // the received transition is what keeps the reconnect sweep's TTL window
579
- // (and the indicator lifecycle) working even for agent-id-less old configs.
583
+ // session reporting — the URL needs no agent id, and the claim transition
584
+ // is what keeps the indicator lifecycle working even for agent-id-less old
585
+ // configs.
580
586
  try {
581
- const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
587
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/claim`;
582
588
  const res = await fetch(url, {
583
589
  method: 'POST',
584
590
  headers: jsonHeaders(ctx),
@@ -586,10 +592,10 @@ export async function markDispatchReceived(ctx, sourceType, sourceId) {
586
592
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
587
593
  });
588
594
  if (!res.ok)
589
- console.error(`[parel-channel] mark received failed (${res.status})`);
595
+ console.error(`[parel-channel] claim source failed (${res.status})`);
590
596
  }
591
597
  catch (err) {
592
- console.error('[parel-channel] mark received failed', err);
598
+ console.error('[parel-channel] claim source failed', err);
593
599
  }
594
600
  }
595
601
  // ── By-source ledger surface ────────────────────────────
@@ -632,6 +638,21 @@ export async function writeActiveEmit(ctx, sourceType, sourceId, envelopeId) {
632
638
  export async function clearActiveEmit(ctx, sourceType, sourceId) {
633
639
  await ctx.store?.delete(activeEmitKey(sourceType, sourceId)).catch(() => { });
634
640
  }
641
+ /**
642
+ * Burn the fence after an ERROR close: keep the attempt id (so the re-driven
643
+ * emit's nextEnvelopeId picks a fresh `#r{n+1}` — a bare repeat of the base
644
+ * id could be deduped by parel and strand the retry) but zero the timestamp
645
+ * (so the redelivery-skip window is already over and the server's re-drive
646
+ * hint re-emits immediately instead of hiding behind a 30-min TTL).
647
+ */
648
+ export async function burnActiveEmit(ctx, sourceType, sourceId) {
649
+ const prior = await readActiveEmit(ctx, sourceType, sourceId);
650
+ if (!prior)
651
+ return;
652
+ await ctx.store
653
+ ?.set(activeEmitKey(sourceType, sourceId), { id: prior.id, at: 0 })
654
+ .catch(() => { });
655
+ }
635
656
  /**
636
657
  * Deterministic envelope id for a source: `{sourceType}:{sourceId}` on the
637
658
  * first attempt, `…#r{n}` on re-emits. Attempt-suffixed ids are unique per
@@ -661,47 +682,65 @@ export function sourceFromEnvelopeId(envelopeId) {
661
682
  * never arrive and this is the ONLY terminal ledger hook — without it a
662
683
  * received row would re-emit on every TTL sweep forever). Only sources whose
663
684
  * `active:{source}` store row matches the envelope id EXACTLY are completed
664
- * (attempt fence); the fence rows clear on success. Both hooks running is
665
- * harmless: whichever completes first consumes the fence, the other skips.
685
+ * (attempt fence). Both hooks running is harmless: whichever completes first
686
+ * consumes the fence, the other skips.
687
+ *
688
+ * Fence disposal follows the outcome: an "ok" close clears the fences (the
689
+ * sources are terminal); an "error" close BURNS them instead — the server
690
+ * released the uncovered members back to pending and its re-drive hint
691
+ * re-emits them, so the burned fence must neither hide the retry behind the
692
+ * redelivery-skip TTL nor let it reuse a dedupe-prone envelope id.
666
693
  */
667
- export async function completeFencedEnvelopes(ctx, envelopeIds) {
694
+ export async function completeFencedEnvelopes(ctx, envelopeIds, outcome = 'ok') {
668
695
  const fenced = [];
669
696
  for (const envId of envelopeIds) {
670
697
  const src = sourceFromEnvelopeId(envId);
671
698
  if (!src)
672
699
  continue; // pre-alignment envelope id (dsp_*) — old rows were acked on emit
673
700
  const active = await readActiveEmit(ctx, src.sourceType, src.sourceId);
674
- if (active?.id === envId) {
701
+ // A BURNED fence (at === 0) still carries the attempt id but the attempt
702
+ // is already error-closed — its members are back in pending awaiting the
703
+ // re-drive. The redundant deliver()-side close for the same envelope
704
+ // must skip here, or its "ok" would no_action-sweep the very rows the
705
+ // error close just released (the retry hole S3a closes).
706
+ if (active?.id === envId && active.at > 0) {
675
707
  fenced.push({ source_type: src.sourceType, source_id: src.sourceId });
676
708
  }
677
709
  }
678
- if (fenced.length > 0 && (await completeSources(ctx, fenced))) {
710
+ if (fenced.length > 0 && (await completeSources(ctx, fenced, outcome))) {
679
711
  for (const src of fenced) {
680
- await clearActiveEmit(ctx, src.source_type, src.source_id);
712
+ if (outcome === 'error') {
713
+ await burnActiveEmit(ctx, src.source_type, src.source_id);
714
+ }
715
+ else {
716
+ await clearActiveEmit(ctx, src.source_type, src.source_id);
717
+ }
681
718
  }
682
719
  }
683
720
  }
684
721
  /**
685
- * Turn-end ledger close: resolves the turn's folded sources on the server
686
- * broad-cover to the turn's reply Effect when one exists, no_action sweep
687
- * otherwise (the server decides; POST /dispatch/complete-sources, idempotent).
688
- * One immediate retry on failure; a still-failed call leaves the rows
689
- * received and the catch-up TTL re-emit self-heals.
722
+ * Turn-end ledger close: resolves the turn's folded sources on the server via
723
+ * the unified complete's source form (POST /dispatch/complete, idempotent)
724
+ * broad-cover to the turn's reply Effect when one exists; otherwise a
725
+ * no_action sweep on "ok", or a release back to pending on the redrive budget
726
+ * on "error" (a failed turn must not silently consume work it never handled).
727
+ * One immediate retry on failure; a still-failed call leaves the rows live
728
+ * and the catch-up TTL re-emit self-heals.
690
729
  */
691
- export async function completeSources(ctx, sources) {
692
- // Ledger surface — not gated on reportingEnabled (see markDispatchReceived).
730
+ export async function completeSources(ctx, sources, outcome = 'ok') {
731
+ // Ledger surface — not gated on reportingEnabled (see claimDispatchSource).
693
732
  if (sources.length === 0)
694
733
  return true;
695
- const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete-sources`;
734
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete`;
696
735
  const attempt = async () => {
697
736
  const res = await fetch(url, {
698
737
  method: 'POST',
699
738
  headers: jsonHeaders(ctx),
700
- body: JSON.stringify({ sources }),
739
+ body: JSON.stringify({ sources, turn_outcome: outcome }),
701
740
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
702
741
  });
703
742
  if (!res.ok)
704
- console.error(`[parel-channel] complete-sources failed (${res.status})`);
743
+ console.error(`[parel-channel] complete (sources) failed (${res.status})`);
705
744
  return res.ok;
706
745
  };
707
746
  try {
@@ -715,7 +754,7 @@ export async function completeSources(ctx, sources) {
715
754
  return await attempt();
716
755
  }
717
756
  catch (err) {
718
- console.error('[parel-channel] complete-sources failed', err);
757
+ console.error('[parel-channel] complete (sources) failed', err);
719
758
  return false;
720
759
  }
721
760
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/parel-channel",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "Parall channel plugin for the parel runtime — lets a parel agent treat Parall as a managed_ws channel (receive dispatches, reply via the Parall API)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/delivery.ts CHANGED
@@ -99,7 +99,7 @@ export async function buildParallDelivery(
99
99
  // ON and observe OFF — the platform template always sets both together,
100
100
  // so it exists only on a hand-crafted binding.
101
101
  if (route.envelopeId) {
102
- await completeFencedEnvelopes(ctx, [route.envelopeId]);
102
+ await completeFencedEnvelopes(ctx, [route.envelopeId], 'ok');
103
103
  }
104
104
 
105
105
  const subject = route.chatId ?? route.conversationId;
@@ -3,11 +3,11 @@ import { channelCapabilityEnv } from './channel-capability.js';
3
3
  import { buildChannelPrompt } from './channel-prompt.js';
4
4
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
5
5
  import {
6
- ackEffect,
6
+ claimDispatchSource,
7
+ completeSourceEffect,
7
8
  createChannelInputStep,
8
9
  ensureSession,
9
10
  invalidateSession,
10
- markDispatchReceived,
11
11
  nextEnvelopeId,
12
12
  patchSession,
13
13
  readActiveEmit,
@@ -62,7 +62,7 @@ export async function effectsForChannelDispatch(
62
62
  if (m.status === 404 || m.status === 403) {
63
63
  // Deleted / inaccessible (also the shape a disabled org feature flag
64
64
  // takes) — permanently unusable, drop it.
65
- return [ackEffect(ctx, 'channel_message', sourceId)];
65
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
66
66
  }
67
67
  return []; // transient: keep pending, the catch-up sweep retries
68
68
  }
@@ -78,19 +78,19 @@ export async function effectsForChannelDispatch(
78
78
  const text = msg?.text ?? '';
79
79
  if (!conversationId) {
80
80
  // No conversation → no session key and no reply target: unusable.
81
- return [ackEffect(ctx, 'channel_message', sourceId)];
81
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
82
82
  }
83
83
  if (!text) {
84
84
  // The ingress adapter only persists mechanically parsed text; a row with
85
85
  // none will never grow any (same reasoning as attachment-only chat
86
86
  // messages) — drop rather than let it pile up in the pending queue.
87
- return [ackEffect(ctx, 'channel_message', sourceId)];
87
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
88
88
  }
89
89
 
90
90
  const c = await get(`/channel-conversations/${conversationId}`);
91
91
  if (!c.ok) {
92
92
  if (c.status === 404 || c.status === 403) {
93
- return [ackEffect(ctx, 'channel_message', sourceId)];
93
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
94
94
  }
95
95
  return [];
96
96
  }
@@ -115,7 +115,7 @@ export async function effectsForChannelDispatch(
115
115
  // (see the comments there): received settles before this function returns;
116
116
  // the turnEnvelope marker goes down before any session write; the row
117
117
  // stays 'received' until the turn event's complete-sources.
118
- const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
118
+ const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
119
119
  const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
120
120
  const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
121
121
  let sessionId = await ensureSession(ctx);
package/src/inbound.ts CHANGED
@@ -28,8 +28,9 @@ import {
28
28
  } from './fork.js';
29
29
  import {
30
30
  ackByIdEffect,
31
- ackEffect,
31
+ claimDispatchSource,
32
32
  completeFencedEnvelopes,
33
+ completeSourceEffect,
33
34
  conversationKey,
34
35
  createErrorStep,
35
36
  fetchMainSessionFresh,
@@ -41,7 +42,6 @@ import {
41
42
  ensureSession,
42
43
  invalidateChildSession,
43
44
  invalidateSession,
44
- markDispatchReceived,
45
45
  nextEnvelopeId,
46
46
  patchSession,
47
47
  readActiveEmit,
@@ -395,7 +395,7 @@ async function effectsForDispatch(
395
395
  if (sourceType !== 'message' || !sourceId) return [];
396
396
  if (!chatId) {
397
397
  // A message dispatch with no chat can never be routed or replied to.
398
- return [ackEffect(ctx, sourceType, sourceId)];
398
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
399
399
  }
400
400
  if (await shouldSkipLiveRow(ctx, sourceType, sourceId)) return [];
401
401
 
@@ -411,7 +411,7 @@ async function effectsForDispatch(
411
411
  if (!m.ok) {
412
412
  if (m.status === 404 || m.status === 403) {
413
413
  // Deleted message / lost chat access — permanently unusable, drop it.
414
- return [ackEffect(ctx, sourceType, sourceId)];
414
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
415
415
  }
416
416
  // Transient failure: keep pending, the catch-up sweep retries.
417
417
  return [];
@@ -444,7 +444,7 @@ async function effectsForDispatch(
444
444
  // Truly empty messages never grow content — ack-and-drop rather than let
445
445
  // them pile up in the pending queue forever. (Attachment-only messages
446
446
  // used to take this path; they now ride the attachment lines instead.)
447
- return [ackEffect(ctx, sourceType, sourceId)];
447
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
448
448
  }
449
449
 
450
450
  // Frame the message with its chat of origin: all chats share the agent's
@@ -537,7 +537,7 @@ async function effectsForDispatch(
537
537
  // Progress reporting lands on the CHILD's ase_ — its turn does the work,
538
538
  // and the replyRoute carries childRef so deliver() links the reply to the
539
539
  // child session (and idles IT, not main).
540
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
540
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
541
541
  const childAse = await ensureChildSession(ctx, decision.childRef);
542
542
  if (childAse) {
543
543
  const results = await Promise.all([
@@ -595,7 +595,9 @@ async function effectsForDispatch(
595
595
  }
596
596
  return [
597
597
  { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
598
- ackEffect(ctx, sourceType, sourceId),
598
+ // Fork-handled rows keep the legacy ack machinery (ledger parity is a
599
+ // declared follow-up); by id — the precise form.
600
+ dispatchId ? ackByIdEffect(ctx, dispatchId) : completeSourceEffect(ctx, sourceType, sourceId),
599
601
  ];
600
602
  }
601
603
 
@@ -642,7 +644,7 @@ async function effectsForDispatch(
642
644
  }
643
645
  }
644
646
  if (!spawnBlackHoled) {
645
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
647
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
646
648
  const childAse = await ensureChildSession(ctx, decision.childRef);
647
649
  if (childAse) {
648
650
  const results = await Promise.all([
@@ -688,7 +690,7 @@ async function effectsForDispatch(
688
690
  // pending→received transition. It is deliberately NOT gated on the
689
691
  // session resolving: a transient session failure must not also cost the
690
692
  // indicator.
691
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
693
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
692
694
  let sessionId = await ensureSession(ctx);
693
695
  if (sessionId) {
694
696
  // The guard marker goes down BEFORE any session write: a stale
@@ -757,7 +759,7 @@ async function effectsForTypedDispatch(
757
759
  data: DispatchData,
758
760
  ctx: ConnectorContext,
759
761
  ): Promise<ConnectorEffect[]> {
760
- // No markDispatchReceived here ON PURPOSE: typed dispatches have no
762
+ // No claimDispatchSource here ON PURPOSE: typed dispatches have no
761
763
  // message-level activity indicator (DispatchResolvedData: "typed
762
764
  // dispatches have no indicator"), so there is no pending ring to light —
763
765
  // the typed input step below is the session panel's timeline entry.
@@ -767,11 +769,21 @@ async function effectsForTypedDispatch(
767
769
  // Typed WorkItems ack by id, never by source: sibling rows (a task
768
770
  // mutation's task_assign + task_update) share (source_type, source_id),
769
771
  // and the source ack would terminally resolve work that was never emitted.
770
- const ack = () =>
771
- dispatchId ? ackByIdEffect(ctx, dispatchId) : ackEffect(ctx, sourceType, sourceId);
772
+ const ack = (): ConnectorEffect[] => {
773
+ if (!dispatchId) {
774
+ // No WorkItem id (malformed payload): the by-source form would
775
+ // terminally resolve SIBLING typed rows sharing this source — leave
776
+ // the row pending for the sweep instead.
777
+ console.error(
778
+ `[parel-channel] typed dispatch without id (${sourceType}:${sourceId}) left pending`,
779
+ );
780
+ return [];
781
+ }
782
+ return [ackByIdEffect(ctx, dispatchId)];
783
+ };
772
784
  const plan = await planTypedDispatch(data, ctx);
773
785
  if (plan.outcome === 'pending') return [];
774
- if (plan.outcome === 'stale') return [ack()];
786
+ if (plan.outcome === 'stale') return ack();
775
787
 
776
788
  const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
777
789
  let sessionId = await ensureSession(ctx);
@@ -841,7 +853,7 @@ async function effectsForTypedDispatch(
841
853
  ...(Object.keys(typedContext).length > 0 ? { context: typedContext } : {}),
842
854
  },
843
855
  },
844
- ack(),
856
+ ...ack(),
845
857
  ];
846
858
  }
847
859
 
@@ -1002,7 +1014,11 @@ async function handleParallAgentEventInner(
1002
1014
  // the absorbing turn's envelopeIds and settle here naturally. The
1003
1015
  // server decides reply broad-cover vs no_action per batch; turn_failed
1004
1016
  // sweeps the same way with the error step below as the visible record.
1005
- await completeFencedEnvelopes(ctx, event.envelopeIds);
1017
+ await completeFencedEnvelopes(
1018
+ ctx,
1019
+ event.envelopeIds,
1020
+ event.type === 'turn_failed' ? 'error' : 'ok',
1021
+ );
1006
1022
 
1007
1023
  // Staleness guard: only idle if this event's turn covers the AGENT'S
1008
1024
  // latest emitted envelope (the marker is agent-global — all chats share
package/src/session.ts CHANGED
@@ -590,12 +590,14 @@ export async function createTraceStep(
590
590
  }
591
591
 
592
592
  /**
593
- * Ack a dispatch by source so it leaves the pending queue (idempotent).
594
- * Typed as the fetch effect so it can ride BOTH hook channels (onMessage's
595
- * ConnectorEffect[] and onAgentEvent's AgentEventEffect[] — the fork spawn
596
- * ack fires from a child agent event).
593
+ * Terminally drop ONE source's live WorkItem via the unified complete's
594
+ * source form (no covering reply Effect exists for a dropped row, so the
595
+ * server sweeps it to no_action — the drop semantics; idempotent). Typed as
596
+ * the fetch effect so it can ride BOTH hook channels (ConnectorEffect[] and
597
+ * AgentEventEffect[]). Replaces the legacy by-source ack for drop cases —
598
+ * that endpoint retires at S3 (dispatch-convergence-design.md §3).
597
599
  */
598
- export function ackEffect(
600
+ export function completeSourceEffect(
599
601
  ctx: ConnectorContext,
600
602
  sourceType: string,
601
603
  sourceId: string,
@@ -603,15 +605,15 @@ export function ackEffect(
603
605
  return {
604
606
  type: 'fetch',
605
607
  request: {
606
- url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/ack`,
608
+ url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete`,
607
609
  method: 'POST',
608
610
  headers: {
609
611
  Authorization: `Bearer ${requireAgk(ctx)}`,
610
612
  'Content-Type': 'application/json',
611
613
  },
612
- body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
614
+ body: JSON.stringify({ sources: [{ source_type: sourceType, source_id: sourceId }] }),
613
615
  },
614
- idempotencyKey: `ack:${sourceType}:${sourceId}`,
616
+ idempotencyKey: `complete:src:${sourceType}:${sourceId}`,
615
617
  };
616
618
  }
617
619
 
@@ -643,31 +645,35 @@ export function ackByIdEffect(
643
645
  }
644
646
 
645
647
  /**
646
- * Lights the pending ring (server broadcasts dispatch.received). Must COMPLETE
647
- * before the ack effect is returned: MarkReceived only transitions rows still
648
- * in status='pending', and ack moves the same row to 'acked' losing the race
649
- * means the ring never appears.
648
+ * Claim the source's WorkItem before the turn (server folds the row into its
649
+ * message lane, flips it pending→received and broadcasts dispatch.received
650
+ * the pending ring rides the claim side effect now; the retired received
651
+ * endpoint was a bare receipt). Best-effort like the receipt was: a refused
652
+ * or failed claim never blocks the emit — the row stays pending and the
653
+ * turn's complete resolves it just the same. Must COMPLETE before any
654
+ * settling effect is returned so a same-batch complete can't race the
655
+ * pending→received transition.
650
656
  */
651
- export async function markDispatchReceived(
657
+ export async function claimDispatchSource(
652
658
  ctx: ConnectorContext,
653
659
  sourceType: string,
654
660
  sourceId: string,
655
661
  ): Promise<void> {
656
662
  // Deliberately NOT gated on reportingEnabled: this is ledger surface, not
657
- // session reporting — the URL needs no agent id, and with ack-on-emit gone
658
- // the received transition is what keeps the reconnect sweep's TTL window
659
- // (and the indicator lifecycle) working even for agent-id-less old configs.
663
+ // session reporting — the URL needs no agent id, and the claim transition
664
+ // is what keeps the indicator lifecycle working even for agent-id-less old
665
+ // configs.
660
666
  try {
661
- const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
667
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/claim`;
662
668
  const res = await fetch(url, {
663
669
  method: 'POST',
664
670
  headers: jsonHeaders(ctx),
665
671
  body: JSON.stringify({ source_type: sourceType, source_id: sourceId }),
666
672
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
667
673
  });
668
- if (!res.ok) console.error(`[parel-channel] mark received failed (${res.status})`);
674
+ if (!res.ok) console.error(`[parel-channel] claim source failed (${res.status})`);
669
675
  } catch (err) {
670
- console.error('[parel-channel] mark received failed', err);
676
+ console.error('[parel-channel] claim source failed', err);
671
677
  }
672
678
  }
673
679
 
@@ -735,6 +741,25 @@ export async function clearActiveEmit(
735
741
  await ctx.store?.delete(activeEmitKey(sourceType, sourceId)).catch(() => {});
736
742
  }
737
743
 
744
+ /**
745
+ * Burn the fence after an ERROR close: keep the attempt id (so the re-driven
746
+ * emit's nextEnvelopeId picks a fresh `#r{n+1}` — a bare repeat of the base
747
+ * id could be deduped by parel and strand the retry) but zero the timestamp
748
+ * (so the redelivery-skip window is already over and the server's re-drive
749
+ * hint re-emits immediately instead of hiding behind a 30-min TTL).
750
+ */
751
+ export async function burnActiveEmit(
752
+ ctx: ConnectorContext,
753
+ sourceType: string,
754
+ sourceId: string,
755
+ ): Promise<void> {
756
+ const prior = await readActiveEmit(ctx, sourceType, sourceId);
757
+ if (!prior) return;
758
+ await ctx.store
759
+ ?.set(activeEmitKey(sourceType, sourceId), { id: prior.id, at: 0 })
760
+ .catch(() => {});
761
+ }
762
+
738
763
  /**
739
764
  * Deterministic envelope id for a source: `{sourceType}:{sourceId}` on the
740
765
  * first attempt, `…#r{n}` on re-emits. Attempt-suffixed ids are unique per
@@ -763,6 +788,9 @@ export function sourceFromEnvelopeId(
763
788
  return { sourceType: bare.slice(0, i), sourceId: bare.slice(i + 1) };
764
789
  }
765
790
 
791
+ /** Turn outcome for the ledger close — mirrors the server's turn_outcome. */
792
+ export type TurnOutcome = 'ok' | 'error';
793
+
766
794
  /**
767
795
  * Fence-checked turn-end ledger close for a set of envelope ids — the shared
768
796
  * core of BOTH turn-end hooks: onAgentEvent (turn_completed/turn_failed, the
@@ -770,51 +798,70 @@ export function sourceFromEnvelopeId(
770
798
  * never arrive and this is the ONLY terminal ledger hook — without it a
771
799
  * received row would re-emit on every TTL sweep forever). Only sources whose
772
800
  * `active:{source}` store row matches the envelope id EXACTLY are completed
773
- * (attempt fence); the fence rows clear on success. Both hooks running is
774
- * harmless: whichever completes first consumes the fence, the other skips.
801
+ * (attempt fence). Both hooks running is harmless: whichever completes first
802
+ * consumes the fence, the other skips.
803
+ *
804
+ * Fence disposal follows the outcome: an "ok" close clears the fences (the
805
+ * sources are terminal); an "error" close BURNS them instead — the server
806
+ * released the uncovered members back to pending and its re-drive hint
807
+ * re-emits them, so the burned fence must neither hide the retry behind the
808
+ * redelivery-skip TTL nor let it reuse a dedupe-prone envelope id.
775
809
  */
776
810
  export async function completeFencedEnvelopes(
777
811
  ctx: ConnectorContext,
778
812
  envelopeIds: string[],
813
+ outcome: TurnOutcome = 'ok',
779
814
  ): Promise<void> {
780
815
  const fenced: { source_type: string; source_id: string }[] = [];
781
816
  for (const envId of envelopeIds) {
782
817
  const src = sourceFromEnvelopeId(envId);
783
818
  if (!src) continue; // pre-alignment envelope id (dsp_*) — old rows were acked on emit
784
819
  const active = await readActiveEmit(ctx, src.sourceType, src.sourceId);
785
- if (active?.id === envId) {
820
+ // A BURNED fence (at === 0) still carries the attempt id but the attempt
821
+ // is already error-closed — its members are back in pending awaiting the
822
+ // re-drive. The redundant deliver()-side close for the same envelope
823
+ // must skip here, or its "ok" would no_action-sweep the very rows the
824
+ // error close just released (the retry hole S3a closes).
825
+ if (active?.id === envId && active.at > 0) {
786
826
  fenced.push({ source_type: src.sourceType, source_id: src.sourceId });
787
827
  }
788
828
  }
789
- if (fenced.length > 0 && (await completeSources(ctx, fenced))) {
829
+ if (fenced.length > 0 && (await completeSources(ctx, fenced, outcome))) {
790
830
  for (const src of fenced) {
791
- await clearActiveEmit(ctx, src.source_type, src.source_id);
831
+ if (outcome === 'error') {
832
+ await burnActiveEmit(ctx, src.source_type, src.source_id);
833
+ } else {
834
+ await clearActiveEmit(ctx, src.source_type, src.source_id);
835
+ }
792
836
  }
793
837
  }
794
838
  }
795
839
 
796
840
  /**
797
- * Turn-end ledger close: resolves the turn's folded sources on the server
798
- * broad-cover to the turn's reply Effect when one exists, no_action sweep
799
- * otherwise (the server decides; POST /dispatch/complete-sources, idempotent).
800
- * One immediate retry on failure; a still-failed call leaves the rows
801
- * received and the catch-up TTL re-emit self-heals.
841
+ * Turn-end ledger close: resolves the turn's folded sources on the server via
842
+ * the unified complete's source form (POST /dispatch/complete, idempotent)
843
+ * broad-cover to the turn's reply Effect when one exists; otherwise a
844
+ * no_action sweep on "ok", or a release back to pending on the redrive budget
845
+ * on "error" (a failed turn must not silently consume work it never handled).
846
+ * One immediate retry on failure; a still-failed call leaves the rows live
847
+ * and the catch-up TTL re-emit self-heals.
802
848
  */
803
849
  export async function completeSources(
804
850
  ctx: ConnectorContext,
805
851
  sources: { source_type: string; source_id: string }[],
852
+ outcome: TurnOutcome = 'ok',
806
853
  ): Promise<boolean> {
807
- // Ledger surface — not gated on reportingEnabled (see markDispatchReceived).
854
+ // Ledger surface — not gated on reportingEnabled (see claimDispatchSource).
808
855
  if (sources.length === 0) return true;
809
- const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete-sources`;
856
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete`;
810
857
  const attempt = async (): Promise<boolean> => {
811
858
  const res = await fetch(url, {
812
859
  method: 'POST',
813
860
  headers: jsonHeaders(ctx),
814
- body: JSON.stringify({ sources }),
861
+ body: JSON.stringify({ sources, turn_outcome: outcome }),
815
862
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
816
863
  });
817
- if (!res.ok) console.error(`[parel-channel] complete-sources failed (${res.status})`);
864
+ if (!res.ok) console.error(`[parel-channel] complete (sources) failed (${res.status})`);
818
865
  return res.ok;
819
866
  };
820
867
  try {
@@ -825,7 +872,7 @@ export async function completeSources(
825
872
  try {
826
873
  return await attempt();
827
874
  } catch (err) {
828
- console.error('[parel-channel] complete-sources failed', err);
875
+ console.error('[parel-channel] complete (sources) failed', err);
829
876
  return false;
830
877
  }
831
878
  }