@parall/parel-channel 1.41.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/src/session.ts CHANGED
@@ -653,7 +653,10 @@ export async function markDispatchReceived(
653
653
  sourceType: string,
654
654
  sourceId: string,
655
655
  ): Promise<void> {
656
- if (!reportingEnabled(ctx)) return;
656
+ // 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.
657
660
  try {
658
661
  const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/received`;
659
662
  const res = await fetch(url, {
@@ -667,3 +670,200 @@ export async function markDispatchReceived(
667
670
  console.error('[parel-channel] mark received failed', err);
668
671
  }
669
672
  }
673
+
674
+ // ── By-source ledger surface ────────────────────────────
675
+ //
676
+ // The connector consumes dispatches without claiming lanes (parel's own
677
+ // queue serializes turns), so its ledger writes address WorkItems by source
678
+ // pointer, and its liveness bookkeeping is a per-source attempt fence in the
679
+ // durable store. Design: parel-reply-contract-alignment.md §5.3.
680
+
681
+ /**
682
+ * How long an emitted source may stay unresolved before the reconnect sweep
683
+ * re-emits it (crash / lost turn_completed self-heal — the successor of the
684
+ * retired turnwatch timer). Must comfortably exceed any legitimate turn.
685
+ */
686
+ export const EMIT_TTL_MS = 30 * 60 * 1000;
687
+
688
+ /** The current emit attempt for a source: its envelope id + emit time. */
689
+ export type ActiveEmit = { id: string; at: number };
690
+
691
+ function activeEmitKey(sourceType: string, sourceId: string): string {
692
+ return `active:${sourceType}:${sourceId}`;
693
+ }
694
+
695
+ /**
696
+ * Per-source attempt fence. Written before emit, cleared by the matching
697
+ * turn event's complete: a turn event only closes a source whose stored id
698
+ * EXACTLY matches one of its envelopeIds — a late turn_completed from a
699
+ * superseded attempt (the source was re-emitted after the TTL) must not
700
+ * close the in-flight retry's work (codex P2 attempt fence).
701
+ */
702
+ export async function readActiveEmit(
703
+ ctx: ConnectorContext,
704
+ sourceType: string,
705
+ sourceId: string,
706
+ ): Promise<ActiveEmit | null> {
707
+ const raw = await ctx.store?.get(activeEmitKey(sourceType, sourceId)).catch(() => null);
708
+ if (
709
+ raw &&
710
+ typeof raw === 'object' &&
711
+ typeof (raw as { id?: unknown }).id === 'string' &&
712
+ typeof (raw as { at?: unknown }).at === 'number'
713
+ ) {
714
+ return raw as ActiveEmit;
715
+ }
716
+ return null;
717
+ }
718
+
719
+ export async function writeActiveEmit(
720
+ ctx: ConnectorContext,
721
+ sourceType: string,
722
+ sourceId: string,
723
+ envelopeId: string,
724
+ ): Promise<void> {
725
+ await ctx.store
726
+ ?.set(activeEmitKey(sourceType, sourceId), { id: envelopeId, at: ctx.now() })
727
+ .catch(() => {});
728
+ }
729
+
730
+ export async function clearActiveEmit(
731
+ ctx: ConnectorContext,
732
+ sourceType: string,
733
+ sourceId: string,
734
+ ): Promise<void> {
735
+ await ctx.store?.delete(activeEmitKey(sourceType, sourceId)).catch(() => {});
736
+ }
737
+
738
+ /**
739
+ * Deterministic envelope id for a source: `{sourceType}:{sourceId}` on the
740
+ * first attempt, `…#r{n}` on re-emits. Attempt-suffixed ids are unique per
741
+ * attempt, so a re-emit is a NEW envelope to parel regardless of any dedupe
742
+ * behavior — the retry always produces a fresh turn.
743
+ */
744
+ export function nextEnvelopeId(
745
+ sourceType: string,
746
+ sourceId: string,
747
+ prior: ActiveEmit | null,
748
+ ): string {
749
+ const base = `${sourceType}:${sourceId}`;
750
+ if (!prior) return base;
751
+ const m = prior.id.match(/#r(\d+)$/);
752
+ const attempt = m ? Number(m[1]) + 1 : 1;
753
+ return `${base}#r${attempt}`;
754
+ }
755
+
756
+ /** envelope id → source pointer (strips the attempt suffix). */
757
+ export function sourceFromEnvelopeId(
758
+ envelopeId: string,
759
+ ): { sourceType: string; sourceId: string } | null {
760
+ const bare = envelopeId.replace(/#r\d+$/, '');
761
+ const i = bare.indexOf(':');
762
+ if (i <= 0 || i === bare.length - 1) return null;
763
+ return { sourceType: bare.slice(0, i), sourceId: bare.slice(i + 1) };
764
+ }
765
+
766
+ /**
767
+ * Fence-checked turn-end ledger close for a set of envelope ids — the shared
768
+ * core of BOTH turn-end hooks: onAgentEvent (turn_completed/turn_failed, the
769
+ * primary) and deliver (the no-observe npm-fallback path, where turn events
770
+ * never arrive and this is the ONLY terminal ledger hook — without it a
771
+ * received row would re-emit on every TTL sweep forever). Only sources whose
772
+ * `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.
775
+ */
776
+ export async function completeFencedEnvelopes(
777
+ ctx: ConnectorContext,
778
+ envelopeIds: string[],
779
+ ): Promise<void> {
780
+ const fenced: { source_type: string; source_id: string }[] = [];
781
+ for (const envId of envelopeIds) {
782
+ const src = sourceFromEnvelopeId(envId);
783
+ if (!src) continue; // pre-alignment envelope id (dsp_*) — old rows were acked on emit
784
+ const active = await readActiveEmit(ctx, src.sourceType, src.sourceId);
785
+ if (active?.id === envId) {
786
+ fenced.push({ source_type: src.sourceType, source_id: src.sourceId });
787
+ }
788
+ }
789
+ if (fenced.length > 0 && (await completeSources(ctx, fenced))) {
790
+ for (const src of fenced) {
791
+ await clearActiveEmit(ctx, src.source_type, src.source_id);
792
+ }
793
+ }
794
+ }
795
+
796
+ /**
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.
802
+ */
803
+ export async function completeSources(
804
+ ctx: ConnectorContext,
805
+ sources: { source_type: string; source_id: string }[],
806
+ ): Promise<boolean> {
807
+ // Ledger surface — not gated on reportingEnabled (see markDispatchReceived).
808
+ if (sources.length === 0) return true;
809
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/dispatch/complete-sources`;
810
+ const attempt = async (): Promise<boolean> => {
811
+ const res = await fetch(url, {
812
+ method: 'POST',
813
+ headers: jsonHeaders(ctx),
814
+ body: JSON.stringify({ sources }),
815
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
816
+ });
817
+ if (!res.ok) console.error(`[parel-channel] complete-sources failed (${res.status})`);
818
+ return res.ok;
819
+ };
820
+ try {
821
+ if (await attempt()) return true;
822
+ } catch {
823
+ // fall through to the single immediate retry
824
+ }
825
+ try {
826
+ return await attempt();
827
+ } catch (err) {
828
+ console.error('[parel-channel] complete-sources failed', err);
829
+ return false;
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Turn-end plain text as a suppressed audit step — the runtime-standard
835
+ * Layer 0 posture (agent-dm-loop-prevention.md): recorded on the session,
836
+ * never delivered to the chat. No `projection` field, so the server's
837
+ * projection gate keeps it panel-only; `suppressed: true` is the content
838
+ * marker the standard bridges write for undelivered text.
839
+ */
840
+ export async function createSuppressedTextStep(
841
+ ctx: ConnectorContext,
842
+ sessionId: string,
843
+ args: { subject: string; deliveryId: string; text: string },
844
+ ): Promise<ReportResult> {
845
+ try {
846
+ const url = `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/agents/${parallAgentId(ctx)}/sessions/${sessionId}/steps`;
847
+ // Same subject-prefix line the other step writers draw: chv_ turns must
848
+ // not target 'chat' or the broadcast lands on a channel no client watches.
849
+ const targetType = args.subject.startsWith('chv_') ? 'channel_conversation' : 'chat';
850
+ const res = await fetch(url, {
851
+ method: 'POST',
852
+ headers: jsonHeaders(ctx),
853
+ body: JSON.stringify({
854
+ step_type: 'text',
855
+ target_type: targetType,
856
+ target_id: args.subject,
857
+ idempotency_key: `text:${args.deliveryId}`,
858
+ content: { text: args.text, suppressed: true },
859
+ }),
860
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
861
+ });
862
+ if (res.ok) return 'ok';
863
+ console.error(`[parel-channel] suppressed text step failed (${res.status})`);
864
+ return resultForStatus(res.status);
865
+ } catch (err) {
866
+ console.error('[parel-channel] suppressed text step failed', err);
867
+ return 'failed';
868
+ }
869
+ }
@@ -22,13 +22,15 @@ import { REQUEST_TIMEOUT_MS } from './session.js';
22
22
  * attached_to_uri legitimately revokes access to a delivered run snapshot;
23
23
  * docs/primitives/schedule.md § Snapshot 不变性) — warn + ack + drop.
24
24
  *
25
- * Reply semantics differ from chat messages ON PURPOSE: a typed turn's
26
- * turn-end text has no chat to deliver to (deliver() only runs the redundant
27
- * idle), and the agent acts through its sandbox CLI instead — identical to
28
- * the standard runtimes, where typed events are answered with CLI actions,
29
- * not auto-delivered text. approval_decided is the one exception: it lives
30
- * in a chat, so its plan carries the chatId and the reply auto-delivers
31
- * there like any chat message.
25
+ * Reply semantics are uniform with the rest of the connector: NOTHING
26
+ * auto-delivers (the CLI-only reply contract,
27
+ * parel-reply-contract-alignment.md). A typed turn's turn-end text is never
28
+ * posted anywhere; the agent acts through its sandbox CLI — identical to
29
+ * the standard runtimes, where typed events are answered with CLI actions.
30
+ * approval_decided differs only in ROUTING metadata: it lives in a chat, so
31
+ * its plan carries the chatId — that anchors the suppressed turn-end audit
32
+ * step and tells the agent where to `parall messages send`; the reply
33
+ * itself is still an explicit CLI send.
32
34
  */
33
35
 
34
36
  export interface TypedDispatchData {
@@ -69,7 +71,11 @@ export interface TypedEventPlan {
69
71
  /** Input-step content bits. */
70
72
  triggerType: string;
71
73
  summary: string;
72
- /** Set only for approval_decided: the reply auto-delivers to this chat. */
74
+ /**
75
+ * Set only for approval_decided: anchors the suppressed turn-end audit
76
+ * step and tells the agent where to `parall messages send` — replies are
77
+ * CLI-only, nothing auto-delivers.
78
+ */
73
79
  chatId?: string;
74
80
  }
75
81
 
@@ -233,8 +239,9 @@ async function planTask(
233
239
  }
234
240
 
235
241
  /**
236
- * schedule.fire / external_trigger turns have NO delivery target — the
237
- * managed-agent base behavior ("your reply auto-delivers") does not apply,
242
+ * schedule.fire / external_trigger turns have NO conversation attached at
243
+ * all — plain text output is never delivered anywhere (the CLI-only reply
244
+ * contract), and unlike chat turns there is no obvious chat to send into,
238
245
  * so without this line a normal completion silently disappears.
239
246
  */
240
247
  function cliOnlyOutputHint(kind: 'schedule' | 'trigger'): string {
@@ -510,7 +517,8 @@ async function planApprovalDecided(
510
517
  return {
511
518
  outcome: 'emit',
512
519
  // The decision belongs to the chat conversation it happened in — group
513
- // and correlate there, and let the turn-end reply deliver to that chat.
520
+ // and correlate there; a follow-up reply is the agent's explicit CLI
521
+ // send into that chat (turn-end text never auto-delivers).
514
522
  subject: resolvedChat ?? approval.id,
515
523
  body: lines.join('\n'),
516
524
  stepTarget: resolvedChat ? { type: 'chat', id: resolvedChat } : null,