@parall/parel-channel 1.42.1 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
  }
@@ -1,63 +0,0 @@
1
- import type { AgentEvent, ChannelEnvelope, ConnectorEffect } from '@parel/plugin-sdk';
2
- /**
3
- * Local mirrors of parel host types that shipped in parel-mono #140 (F3
4
- * injectInFlight / F4 child sessions) but are not yet published in
5
- * @parel/plugin-sdk (the mirror lives on parel-oss branch
6
- * feat/channel-child-sessions). The host accepts these shapes today — prod
7
- * runs #140 — so the connector uses them through this compat layer and casts
8
- * at the hook boundary.
9
- *
10
- * TODO(parel-sdk-#140-types): DELETE this file (and the casts) once
11
- * @parel/plugin-sdk ships the mirror sitting on parel-oss branch
12
- * `feat/channel-child-sessions` — check the branch/`@parel/plugin-sdk`
13
- * changelog on every sdk version bump. Until then the shapes below must stay
14
- * byte-compatible with parel-mono ts/packages/cloudflare/src/channel-types.ts.
15
- */
16
- /**
17
- * Spawn a fork child session off this connection's main conversation session.
18
- * Idempotent on childRef (opaque, connector-owned). Host gates: binding must
19
- * opt in (childSessions) + `main` routing; failures come back asynchronously
20
- * as a `child_spawn_failed` agent event. The child seeds from the parent
21
- * transcript at the last turn boundary (never sees in-flight output).
22
- */
23
- export interface SpawnChildSessionEffect {
24
- type: 'spawnChildSession';
25
- childRef: string;
26
- input: string;
27
- subject?: string;
28
- }
29
- /** emitEvent with the #140 deliverTo extension: route to a spawned child. */
30
- export interface EmitToChildEffect {
31
- type: 'emitEvent';
32
- event: ChannelEnvelope;
33
- deliverTo: {
34
- childRef: string;
35
- };
36
- }
37
- /**
38
- * Asynchronous error channel for a failed spawnChildSession effect (pushed
39
- * regardless of observe scopes). codes: disabled | unsupported_routing |
40
- * no_binding | invalid_request | depth_limit | concurrency_limit |
41
- * spawn_failed.
42
- */
43
- export interface ChildSpawnFailedEvent {
44
- type: 'child_spawn_failed';
45
- childRef: string;
46
- code: string;
47
- error: string;
48
- }
49
- /** Effect union the #140 host actually executes. */
50
- export type ExtendedConnectorEffect = ConnectorEffect | SpawnChildSessionEffect | EmitToChildEffect;
51
- /**
52
- * Every event from a connector-spawned child carries the childRef it was
53
- * spawned with (#140: AgentEventBase.childRef). Absent on main-session
54
- * events and on sdk versions that predate the field.
55
- */
56
- export type AgentEventWithChild = AgentEvent & {
57
- childRef?: string;
58
- };
59
- /** Narrow an incoming agent event to the not-yet-published failure shape. */
60
- export declare function asChildSpawnFailed(event: unknown): ChildSpawnFailedEvent | null;
61
- /** Cast helper: the host executes the extended union; the sdk type lags it. */
62
- export declare function asConnectorEffects(effects: ExtendedConnectorEffect[]): ConnectorEffect[];
63
- //# sourceMappingURL=parel-sdk-compat.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"parel-sdk-compat.d.ts","sourceRoot":"","sources":["../src/parel-sdk-compat.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEtF;;;;;;;;;;;;;GAaG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,mBAAmB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,WAAW,CAAC;IAClB,KAAK,EAAE,eAAe,CAAC;IACvB,SAAS,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACjC;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,oBAAoB,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,oDAAoD;AACpD,MAAM,MAAM,uBAAuB,GAAG,eAAe,GAAG,uBAAuB,GAAG,iBAAiB,CAAC;AAEpG;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErE,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAS/E;AAED,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,uBAAuB,EAAE,GAAG,eAAe,EAAE,CAExF"}
@@ -1,16 +0,0 @@
1
- /** Narrow an incoming agent event to the not-yet-published failure shape. */
2
- export function asChildSpawnFailed(event) {
3
- const e = event;
4
- if (e?.type !== 'child_spawn_failed')
5
- return null;
6
- return {
7
- type: 'child_spawn_failed',
8
- childRef: typeof e.childRef === 'string' ? e.childRef : '',
9
- code: typeof e.code === 'string' ? e.code : 'unknown',
10
- error: typeof e.error === 'string' ? e.error : '',
11
- };
12
- }
13
- /** Cast helper: the host executes the extended union; the sdk type lags it. */
14
- export function asConnectorEffects(effects) {
15
- return effects;
16
- }
@@ -1,77 +0,0 @@
1
- import type { AgentEvent, ChannelEnvelope, ConnectorEffect } from '@parel/plugin-sdk';
2
-
3
- /**
4
- * Local mirrors of parel host types that shipped in parel-mono #140 (F3
5
- * injectInFlight / F4 child sessions) but are not yet published in
6
- * @parel/plugin-sdk (the mirror lives on parel-oss branch
7
- * feat/channel-child-sessions). The host accepts these shapes today — prod
8
- * runs #140 — so the connector uses them through this compat layer and casts
9
- * at the hook boundary.
10
- *
11
- * TODO(parel-sdk-#140-types): DELETE this file (and the casts) once
12
- * @parel/plugin-sdk ships the mirror sitting on parel-oss branch
13
- * `feat/channel-child-sessions` — check the branch/`@parel/plugin-sdk`
14
- * changelog on every sdk version bump. Until then the shapes below must stay
15
- * byte-compatible with parel-mono ts/packages/cloudflare/src/channel-types.ts.
16
- */
17
-
18
- /**
19
- * Spawn a fork child session off this connection's main conversation session.
20
- * Idempotent on childRef (opaque, connector-owned). Host gates: binding must
21
- * opt in (childSessions) + `main` routing; failures come back asynchronously
22
- * as a `child_spawn_failed` agent event. The child seeds from the parent
23
- * transcript at the last turn boundary (never sees in-flight output).
24
- */
25
- export interface SpawnChildSessionEffect {
26
- type: 'spawnChildSession';
27
- childRef: string;
28
- input: string;
29
- subject?: string;
30
- }
31
-
32
- /** emitEvent with the #140 deliverTo extension: route to a spawned child. */
33
- export interface EmitToChildEffect {
34
- type: 'emitEvent';
35
- event: ChannelEnvelope;
36
- deliverTo: { childRef: string };
37
- }
38
-
39
- /**
40
- * Asynchronous error channel for a failed spawnChildSession effect (pushed
41
- * regardless of observe scopes). codes: disabled | unsupported_routing |
42
- * no_binding | invalid_request | depth_limit | concurrency_limit |
43
- * spawn_failed.
44
- */
45
- export interface ChildSpawnFailedEvent {
46
- type: 'child_spawn_failed';
47
- childRef: string;
48
- code: string;
49
- error: string;
50
- }
51
-
52
- /** Effect union the #140 host actually executes. */
53
- export type ExtendedConnectorEffect = ConnectorEffect | SpawnChildSessionEffect | EmitToChildEffect;
54
-
55
- /**
56
- * Every event from a connector-spawned child carries the childRef it was
57
- * spawned with (#140: AgentEventBase.childRef). Absent on main-session
58
- * events and on sdk versions that predate the field.
59
- */
60
- export type AgentEventWithChild = AgentEvent & { childRef?: string };
61
-
62
- /** Narrow an incoming agent event to the not-yet-published failure shape. */
63
- export function asChildSpawnFailed(event: unknown): ChildSpawnFailedEvent | null {
64
- const e = event as { type?: unknown; childRef?: unknown; code?: unknown; error?: unknown };
65
- if (e?.type !== 'child_spawn_failed') return null;
66
- return {
67
- type: 'child_spawn_failed',
68
- childRef: typeof e.childRef === 'string' ? e.childRef : '',
69
- code: typeof e.code === 'string' ? e.code : 'unknown',
70
- error: typeof e.error === 'string' ? e.error : '',
71
- };
72
- }
73
-
74
- /** Cast helper: the host executes the extended union; the sdk type lags it. */
75
- export function asConnectorEffects(effects: ExtendedConnectorEffect[]): ConnectorEffect[] {
76
- return effects as ConnectorEffect[];
77
- }