@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/dist/channel-prompt.d.ts +4 -3
- package/dist/channel-prompt.d.ts.map +1 -1
- package/dist/channel-prompt.js +4 -3
- package/dist/delivery.d.ts +23 -16
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +85 -116
- package/dist/fork.d.ts +8 -5
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +9 -6
- package/dist/inbound-channel.d.ts +1 -1
- package/dist/inbound-channel.d.ts.map +1 -1
- package/dist/inbound-channel.js +13 -7
- package/dist/inbound.d.ts.map +1 -1
- package/dist/inbound.js +87 -20
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +166 -2
- package/dist/typed-dispatch.d.ts +14 -8
- package/dist/typed-dispatch.d.ts.map +1 -1
- package/dist/typed-dispatch.js +5 -3
- package/package.json +1 -1
- package/src/channel-prompt.ts +4 -3
- package/src/delivery.ts +84 -114
- package/src/fork.ts +9 -6
- package/src/inbound-channel.ts +14 -6
- package/src/inbound.ts +109 -23
- package/src/session.ts +201 -1
- package/src/typed-dispatch.ts +19 -11
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
|
-
|
|
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
|
+
}
|
package/src/typed-dispatch.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
237
|
-
*
|
|
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
|
|
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,
|