@parall/parel-channel 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fork.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Fork-on-busy scheduling (the parall-side half of parel F4, parel-mono #140;
3
+ * strategy ownership per parel-session-alignment-plan.md §6 — the platform
4
+ * provides the spawnChildSession primitive, WHEN to fork is connector code).
5
+ *
6
+ * Model, mirroring the standard runtimes' fork-on-busy:
7
+ * - The main session is the single orchestrator. While it is mid-turn, a
8
+ * message for a DIFFERENT conversation would head-of-line block behind it.
9
+ * - Instead, the connector spawns a fork child session (inherits main's
10
+ * transcript at the last turn boundary — platform-guaranteed) keyed by an
11
+ * opaque childRef, and routes that conversation's follow-ups to the child
12
+ * via emitEvent deliverTo.
13
+ * - The child replies through the normal turn-end deliver (its envelopes
14
+ * carry the same replyRoute shape), and its summary flows back to main via
15
+ * the platform's async child notification. Same-conversation messages keep
16
+ * riding main (steer/inject handles mid-turn arrival there).
17
+ *
18
+ * Busy tracking is BEST-EFFORT on the E1 turn stream (turn_started /
19
+ * turn_completed / turn_failed): a missed event mis-classifies busy vs idle,
20
+ * which costs a fork too many or a queued message too long — never
21
+ * correctness (F2 subject grouping guarantees turns don't mix conversations
22
+ * either way). Stale entries expire by TTL so a lost done event cannot wedge
23
+ * the classifier.
24
+ *
25
+ * Ack discipline: the spawn opening message — and any follow-up routed to
26
+ * the child before provisioning is confirmed — is NOT acked on emit; spawn
27
+ * failures come back asynchronously (child_spawn_failed) and an
28
+ * acked-but-never-processed message would be silently lost. Parked acks ride
29
+ * the child bookkeeping row and fire (by WorkItem id, re-sent a bounded
30
+ * number of times) on the child's observed events. On spawn failure the
31
+ * inbound layer arms an onTimer re-drive (FORK_RETRY_DELAY_MS) so the
32
+ * messages are re-planned promptly on a healthy long-lived connection —
33
+ * transient failures re-issue the SAME spawn ref (branch 1a), terminal ones
34
+ * re-plan from scratch (fresh ref or main).
35
+ */
36
+ /** Main-session turn considered over after this long without a done event. */
37
+ const MAIN_TURN_TTL_MS = 15 * 60 * 1000;
38
+ /** A child with no observed activity for this long is considered gone. */
39
+ const CHILD_TTL_MS = 10 * 60 * 1000;
40
+ /** After a disabled/unsupported spawn failure, stop trying to fork for a while. */
41
+ const FORK_DISABLED_TTL_MS = 60 * 60 * 1000;
42
+ /** Delay before a failed spawn's messages are re-driven through onTimer. */
43
+ export const FORK_RETRY_DELAY_MS = 15_000;
44
+ /** Cap on un-acked follow-ups parked on an unconfirmed child. */
45
+ const MAX_PENDING_FOLLOWUPS = 20;
46
+ /** Stop re-sending acks after this many settle rounds. */
47
+ const MAX_ACK_ATTEMPTS = 3;
48
+ const MAIN_TURN_KEY = 'forkMainTurn';
49
+ export const FORK_RETRY_PREFIX = 'forkRetry:';
50
+ const FORK_DISABLED_KEY = 'forkDisabled';
51
+ const childKey = (subject) => `forkChild:${subject}`;
52
+ const childSubjectKey = (childRef) => `forkChildSubject:${childRef}`;
53
+ function isLive(at, ttl, now) {
54
+ return typeof at === 'number' && now - at < ttl;
55
+ }
56
+ /**
57
+ * Decide how to route one inbound chat message. Called on the message hot
58
+ * path — store reads only, never a fetch.
59
+ *
60
+ * Concurrency: the decision-then-record sequence is NOT internally locked —
61
+ * it relies on the parel host executing one connection's hooks serially (the
62
+ * C3 contract; the same guarantee the turnEnvelope marker's read-then-patch
63
+ * ordering already leans on). Two same-chat dispatches therefore cannot both
64
+ * observe "no child" — the second runs after the first's recordSpawn.
65
+ */
66
+ export async function planForkDecision(ctx, subject, sourceId) {
67
+ const now = ctx.now();
68
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
69
+ if (child?.childRef && isLive(child.at, CHILD_TTL_MS, now)) {
70
+ // 1a. A replay of the fork's own opening message: re-issue the spawn
71
+ // with the SAME childRef. The platform spawn is idempotent (same ref
72
+ // → same child, duplicate input suppressed, transient
73
+ // concurrency_limit failures retry through) — this is both the retry
74
+ // path for transient spawn failures and the duplicate-processing
75
+ // guard (a deliverTo here would bypass ingress dedupe, the opening
76
+ // input never was an envelope). Matched on the row-lifetime
77
+ // openingSourceId, not pendingAcks — the acks list can be dropped
78
+ // after bounded re-sends while replays must keep matching.
79
+ if (child.openingSourceId === sourceId) {
80
+ return { mode: 'spawn', childRef: child.childRef };
81
+ }
82
+ // 1b. An existing live child owns its conversation's follow-ups. Until
83
+ // the child's first observed event confirms provisioning, follow-up
84
+ // acks are parked on the row (a deliverTo into a terminally-failed
85
+ // spawn is host-ignored — acking up front would lose the message).
86
+ return { mode: 'deliverTo', childRef: child.childRef, trackAck: child.confirmed !== true };
87
+ }
88
+ // 2. Forking disabled (binding opt-out observed) — everything rides main.
89
+ const disabled = (await ctx.store?.get(FORK_DISABLED_KEY).catch(() => null));
90
+ if (disabled && isLive(disabled.at, FORK_DISABLED_TTL_MS, now)) {
91
+ return { mode: 'main' };
92
+ }
93
+ // 3. Main mid-turn on a DIFFERENT conversation → fork. Same conversation
94
+ // stays on main (inject-in-flight absorbs it there).
95
+ const turn = (await ctx.store?.get(MAIN_TURN_KEY).catch(() => null));
96
+ if (turn?.turnId && isLive(turn.at, MAIN_TURN_TTL_MS, now) && turn.subject !== subject) {
97
+ // now() suffix: a childRef is burned once its spawn fails terminally
98
+ // (platform mapping row is three-state), so a re-plan after a terminal
99
+ // failure must mint a fresh ref; retries of a still-pending spawn go
100
+ // through branch 1a with the recorded ref instead.
101
+ return { mode: 'spawn', childRef: `fk-${now.toString(36)}-${subject}` };
102
+ }
103
+ return { mode: 'main' };
104
+ }
105
+ /** Record a just-issued spawn so follow-ups route to the child. Idempotent
106
+ * for the 1a replay path: an existing row for the same ref keeps its parked
107
+ * follow-up acks and confirmation state, only refreshing liveness. */
108
+ export async function recordSpawn(ctx, subject, childRef, opening) {
109
+ const existing = (await ctx.store?.get(childKey(subject)).catch(() => null));
110
+ const row = existing?.childRef === childRef
111
+ ? { ...existing, at: ctx.now() }
112
+ : { childRef, at: ctx.now(), pendingAcks: [opening], openingSourceId: opening.sourceId };
113
+ await ctx.store?.set(childKey(subject), row).catch(() => { });
114
+ await ctx.store?.set(childSubjectKey(childRef), subject).catch(() => { });
115
+ }
116
+ /**
117
+ * Park a follow-up's WorkItem ack on an unconfirmed child. Returns false when
118
+ * the row is gone or the cap is hit — the caller then acks on emit (the cap
119
+ * bounds the loss window of a pathological never-confirming child; beyond it
120
+ * we prefer at-most-once for the tail over unbounded growth).
121
+ */
122
+ export async function trackFollowUpAck(ctx, subject, childRef, ack) {
123
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
124
+ if (!child || child.childRef !== childRef)
125
+ return false;
126
+ const acks = child.pendingAcks ?? [];
127
+ if (acks.some((a) => a.dispatchId === ack.dispatchId))
128
+ return true; // replay — already parked
129
+ if (acks.length >= MAX_PENDING_FOLLOWUPS)
130
+ return false;
131
+ const row = { ...child, at: ctx.now(), pendingAcks: [...acks, ack] };
132
+ await ctx.store?.set(childKey(subject), row).catch(() => { });
133
+ return true;
134
+ }
135
+ /** Whether a childRef is still part of live fork bookkeeping. Late child
136
+ * callbacks after a New Session purge (or after give-up) must NOT recreate
137
+ * fork ase_ rows via the blind get-or-create — a pre-reset child's delayed
138
+ * trace/reply would attach a fresh session under the NEW conversation line. */
139
+ export async function childRefKnown(ctx, childRef) {
140
+ const subject = (await ctx.store?.get(childSubjectKey(childRef)).catch(() => null));
141
+ if (!subject)
142
+ return false;
143
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
144
+ return child?.childRef === childRef;
145
+ }
146
+ /** Whether a subject's live child has ever produced an observed event. */
147
+ export async function childConfirmed(ctx, subject, childRef) {
148
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
149
+ return child?.childRef === childRef && child.confirmed === true;
150
+ }
151
+ /** Drop one subject's fork routing (give-up path: message re-plans fresh). */
152
+ export async function clearForkSubject(ctx, subject, childRef) {
153
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
154
+ if (child?.childRef === childRef) {
155
+ await ctx.store?.delete(childKey(subject)).catch(() => { });
156
+ }
157
+ await ctx.store?.delete(childSubjectKey(childRef)).catch(() => { });
158
+ }
159
+ /**
160
+ * Bump-and-park a fork retry ledger row (also the spawn-verification
161
+ * coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
162
+ */
163
+ export async function bumpForkRetryAttempts(ctx, retryKey, data) {
164
+ const prior = (await ctx.store?.get(retryKey).catch(() => null));
165
+ const attempts = (prior?.attempts ?? 0) + 1;
166
+ await ctx.store?.set(retryKey, { ...data, attempts }).catch(() => { });
167
+ return attempts;
168
+ }
169
+ /** Give up re-verifying a spawn after this many timer rounds. */
170
+ export const MAX_SPAWN_VERIFY_ATTEMPTS = 3;
171
+ /**
172
+ * Purge ALL fork routing state — called on `agent.new_session`. The binding
173
+ * reset rotates main's conversation line; stale child routing surviving it
174
+ * would deliver post-reset messages into pre-reset forks (old context), and
175
+ * a stale busy marker would fork off a session that no longer exists.
176
+ * Returns the childRefs that were live so the caller can also invalidate
177
+ * their cached ase_ rows.
178
+ */
179
+ export async function clearForkState(ctx) {
180
+ const refs = [];
181
+ try {
182
+ // EVERY fork-scoped key deliberately shares the `fork` prefix
183
+ // (forkChild / forkChildSubject / forkMainTurn / forkDisabled /
184
+ // forkRetry) so this one list-and-delete purges the whole family — a
185
+ // surviving disabled latch would suppress F4 for an hour after a reset,
186
+ // and a surviving retry row would re-drive a pre-reset message into the
187
+ // fresh session. Keep new fork keys on the prefix.
188
+ const keys = (await ctx.store?.list('fork').catch(() => [])) ?? [];
189
+ for (const key of keys) {
190
+ if (key.startsWith('forkChildSubject:')) {
191
+ refs.push(key.slice('forkChildSubject:'.length));
192
+ }
193
+ await ctx.store?.delete(key).catch(() => { });
194
+ }
195
+ }
196
+ catch {
197
+ // Best-effort: leftover rows expire by TTL anyway.
198
+ }
199
+ return refs;
200
+ }
201
+ /**
202
+ * Track main-session busyness from the E1 turn stream. Child events (they
203
+ * carry childRef) must NOT feed this — only the main session's turns do.
204
+ */
205
+ export async function noteMainTurnEvent(ctx, event) {
206
+ if (event.type === 'turn_started') {
207
+ const row = { turnId: event.turnId, subject: event.subject, at: ctx.now() };
208
+ await ctx.store?.set(MAIN_TURN_KEY, row).catch(() => { });
209
+ return;
210
+ }
211
+ if (event.type === 'turn_completed' || event.type === 'turn_failed') {
212
+ const turn = (await ctx.store?.get(MAIN_TURN_KEY).catch(() => null));
213
+ // Clear only the turn this done event belongs to — a stale done for a
214
+ // superseded turn must not mark a newer running turn idle.
215
+ if (!turn || turn.turnId === event.turnId) {
216
+ await ctx.store?.delete(MAIN_TURN_KEY).catch(() => { });
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Resolve a child event's bookkeeping row (by its subject mirror) and fire
222
+ * the pending spawn ack exactly once. Returns the ack request when due.
223
+ */
224
+ export async function settleChildEvent(ctx, event) {
225
+ const childRef = event.childRef;
226
+ if (!childRef)
227
+ return null;
228
+ const subject = event.subject ||
229
+ (await ctx.store?.get(childSubjectKey(childRef)).catch(() => null)) ||
230
+ '';
231
+ if (!subject)
232
+ return null;
233
+ const child = (await ctx.store?.get(childKey(subject)).catch(() => null));
234
+ if (!child || child.childRef !== childRef) {
235
+ // Unknown/superseded child — refresh nothing, ack nothing.
236
+ return { subject, pendingAcks: [] };
237
+ }
238
+ // Acks are re-issued on subsequent events (bounded) rather than cleared on
239
+ // first send: the ack rides a fire-and-forget fetch effect, and clearing
240
+ // eagerly would let one lost request orphan the WorkItem in `received`
241
+ // forever (its replay would then re-enter the child as a duplicate).
242
+ const acks = child.pendingAcks ?? [];
243
+ const attempts = (child.ackAttempts ?? 0) + 1;
244
+ const done = attempts >= MAX_ACK_ATTEMPTS;
245
+ const refreshed = {
246
+ childRef,
247
+ at: ctx.now(),
248
+ confirmed: true,
249
+ // openingSourceId survives the ack-list drop — replay identity is
250
+ // row-lifetime (see the field doc).
251
+ ...(child.openingSourceId ? { openingSourceId: child.openingSourceId } : {}),
252
+ ...(done ? {} : { pendingAcks: acks, ackAttempts: attempts }),
253
+ };
254
+ await ctx.store?.set(childKey(subject), refreshed).catch(() => { });
255
+ return { subject, pendingAcks: acks };
256
+ }
257
+ /**
258
+ * A spawn failed before the child ran. NEVER acks the opening dispatch — it
259
+ * stays received and the catch-up sweep re-plans it. Failure codes split
260
+ * three ways (parel §18.2 three-state mapping semantics):
261
+ * - `concurrency_limit` is TRANSIENT: the platform keeps the mapping in
262
+ * `provisioning` and the same childRef can retry — keep the bookkeeping so
263
+ * the sweep's replay re-issues the same spawn key (branch 1a) instead of
264
+ * abandoning one provisioning row per attempt.
265
+ * - `disabled` / `unsupported_routing` are configuration-shaped: clear the
266
+ * bookkeeping AND park a no-fork flag so the connector stops re-trying a
267
+ * fork the binding will never allow.
268
+ * - Everything else is terminal for this childRef (the ref is burned): clear
269
+ * the bookkeeping so the sweep's re-plan mints a fresh ref.
270
+ * Returns true when the bookkeeping was cleared (the caller then settles the
271
+ * orphaned child ase_).
272
+ */
273
+ export async function handleChildSpawnFailed(ctx, event) {
274
+ console.error(`[parel-channel] child spawn failed (${event.code}): ${event.error} (ref ${event.childRef})`);
275
+ const subject = (await ctx.store?.get(childSubjectKey(event.childRef)).catch(() => null)) ??
276
+ '';
277
+ const child = subject
278
+ ? (await ctx.store?.get(childKey(subject)).catch(() => null))
279
+ : null;
280
+ const pendingAcks = child?.childRef === event.childRef ? (child.pendingAcks ?? []) : [];
281
+ if (event.code === 'concurrency_limit') {
282
+ // Transient: the platform keeps the mapping in `provisioning` and the
283
+ // SAME childRef can retry — keep the bookkeeping; the caller's retry
284
+ // timer re-drives the opening message through branch 1a.
285
+ return { cleared: false, subject, pendingAcks };
286
+ }
287
+ if (subject) {
288
+ if (child?.childRef === event.childRef) {
289
+ await ctx.store?.delete(childKey(subject)).catch(() => { });
290
+ }
291
+ await ctx.store?.delete(childSubjectKey(event.childRef)).catch(() => { });
292
+ }
293
+ if (event.code === 'disabled' || event.code === 'unsupported_routing') {
294
+ await ctx.store?.set(FORK_DISABLED_KEY, { at: ctx.now() }).catch(() => { });
295
+ }
296
+ return { cleared: true, subject, pendingAcks };
297
+ }
298
+ /**
299
+ * Scope guard prepended to a fork child's opening input — the behavioral
300
+ * fallback agent-core applies to every forked dispatch (buildForkScopePrefix
301
+ * equivalent; the platform already excludes in-flight output from the seed,
302
+ * this line keeps the child from "finishing" main's visible work).
303
+ *
304
+ * Replies are CLI-only on EVERY fork turn (the platform reply contract —
305
+ * nothing auto-delivers, parel-reply-contract-alignment.md). The opening
306
+ * turn additionally has no envelope (it starts from the spawn input), so it
307
+ * gets no per-turn invocation context either — the prefix therefore spells
308
+ * out the full CLI reply command with the explicit chat target; follow-ups
309
+ * arrive as deliverTo envelopes whose invocation context presets
310
+ * PRLL_CHAT_ID, so the system prompt's bare `parall messages send` teaching
311
+ * covers them.
312
+ */
313
+ export function buildForkScopePrefix(subject, threadRootId) {
314
+ // Quoted-heredoc input form (mirrors agent-core's send hint): plain
315
+ // `--text "…"` lets the sandbox shell expand $, backticks and eat quotes —
316
+ // and this is the opening turn's ONLY delivery path.
317
+ const send = threadRootId
318
+ ? `parall messages send ${subject} --thread-root-id ${threadRootId} --text-file - <<'EOF'`
319
+ : `parall messages send ${subject} --text-file - <<'EOF'`;
320
+ return (`[Fork scope: you are a parallel branch of your main session, handling ONE conversation (${subject}) only. ` +
321
+ `Act only on this conversation's request. Do not continue, complete, or produce output for other work visible in your inherited history — the main session owns it.]\n` +
322
+ `<system-reminder>Reply to THIS message with your sandbox CLI: \`${send}\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal. Your plain text output is NOT delivered anywhere — on this and every later message in this conversation, reply via \`parall messages send\`.</system-reminder>\n\n`);
323
+ }
@@ -0,0 +1,29 @@
1
+ import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
2
+ /**
3
+ * External IM inbound (channel_message dispatches — Feishu/Slack via the
4
+ * platform-mediated channel, external-im-channel-design.md). Split from
5
+ * inbound.ts as a pure move; the pipeline contract lives there.
6
+ */
7
+ /**
8
+ * Miss-path lookups here are cosmetic (prompt labeling only), so they get a
9
+ * tight timeout — same 3s budget as deliver's front-of-reply session lookup —
10
+ * rather than holding the message's emit/ack behind the full
11
+ * REQUEST_TIMEOUT_MS. Also reused by inbound.ts's chat-name prefetch.
12
+ */
13
+ export declare const PROVIDER_LOOKUP_TIMEOUT_MS = 3000;
14
+ /**
15
+ * channel_message dispatch → agent (external IM channel,
16
+ * external-im-channel-design.md). Same pipeline shape as the message path,
17
+ * with the ChannelConversation (chv_) standing in for the chat as the
18
+ * routing subject: fetch the durable ChannelMessage + its conversation,
19
+ * resolve the provider for prompt labeling (cached — the connection→provider
20
+ * mapping is stable), frame the prompt for the EXTERNAL audience, report
21
+ * progress against the conversation-keyed session, and ack by source.
22
+ *
23
+ * Error contract mirrors the message path (and agent-core's
24
+ * fetchAndHandleChannelMessage): 404/403 on the source entities is
25
+ * permanently unusable → ack-and-drop; a transient failure keeps the
26
+ * dispatch pending for the catch-up sweep.
27
+ */
28
+ export declare function effectsForChannelDispatch(sourceId: string, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
29
+ //# sourceMappingURL=inbound-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAiB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CA6J5B"}
@@ -0,0 +1,219 @@
1
+ import { buildChannelPrompt } from './channel-prompt.js';
2
+ import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
+ import { ackEffect, createChannelInputStep, ensureSession, invalidateSession, markDispatchReceived, nextEnvelopeId, patchSession, readActiveEmit, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
4
+ /**
5
+ * External IM inbound (channel_message dispatches — Feishu/Slack via the
6
+ * platform-mediated channel, external-im-channel-design.md). Split from
7
+ * inbound.ts as a pure move; the pipeline contract lives there.
8
+ */
9
+ /**
10
+ * Miss-path lookups here are cosmetic (prompt labeling only), so they get a
11
+ * tight timeout — same 3s budget as deliver's front-of-reply session lookup —
12
+ * rather than holding the message's emit/ack behind the full
13
+ * REQUEST_TIMEOUT_MS. Also reused by inbound.ts's chat-name prefetch.
14
+ */
15
+ export const PROVIDER_LOOKUP_TIMEOUT_MS = 3_000;
16
+ /**
17
+ * channel_message dispatch → agent (external IM channel,
18
+ * external-im-channel-design.md). Same pipeline shape as the message path,
19
+ * with the ChannelConversation (chv_) standing in for the chat as the
20
+ * routing subject: fetch the durable ChannelMessage + its conversation,
21
+ * resolve the provider for prompt labeling (cached — the connection→provider
22
+ * mapping is stable), frame the prompt for the EXTERNAL audience, report
23
+ * progress against the conversation-keyed session, and ack by source.
24
+ *
25
+ * Error contract mirrors the message path (and agent-core's
26
+ * fetchAndHandleChannelMessage): 404/403 on the source entities is
27
+ * permanently unusable → ack-and-drop; a transient failure keeps the
28
+ * dispatch pending for the catch-up sweep.
29
+ */
30
+ export async function effectsForChannelDispatch(sourceId, ctx) {
31
+ const apiUrl = parallApiUrl(ctx);
32
+ const orgId = parallOrgId(ctx);
33
+ const authHeaders = { Authorization: `Bearer ${requireAgk(ctx)}` };
34
+ const get = (path, timeoutMs = REQUEST_TIMEOUT_MS) => fetch(`${apiUrl}/api/v1/orgs/${orgId}${path}`, {
35
+ headers: authHeaders,
36
+ signal: AbortSignal.timeout(timeoutMs),
37
+ });
38
+ const m = await get(`/channel-messages/${sourceId}`);
39
+ if (!m.ok) {
40
+ if (m.status === 404 || m.status === 403) {
41
+ // Deleted / inaccessible (also the shape a disabled org feature flag
42
+ // takes) — permanently unusable, drop it.
43
+ return [ackEffect(ctx, 'channel_message', sourceId)];
44
+ }
45
+ return []; // transient: keep pending, the catch-up sweep retries
46
+ }
47
+ const msg = (await m.json().catch(() => null));
48
+ const conversationId = msg?.conversation_id ? String(msg.conversation_id) : undefined;
49
+ const text = msg?.text ?? '';
50
+ if (!conversationId) {
51
+ // No conversation → no session key and no reply target: unusable.
52
+ return [ackEffect(ctx, 'channel_message', sourceId)];
53
+ }
54
+ if (!text) {
55
+ // The ingress adapter only persists mechanically parsed text; a row with
56
+ // none will never grow any (same reasoning as attachment-only chat
57
+ // messages) — drop rather than let it pile up in the pending queue.
58
+ return [ackEffect(ctx, 'channel_message', sourceId)];
59
+ }
60
+ const c = await get(`/channel-conversations/${conversationId}`);
61
+ if (!c.ok) {
62
+ if (c.status === 404 || c.status === 403) {
63
+ return [ackEffect(ctx, 'channel_message', sourceId)];
64
+ }
65
+ return [];
66
+ }
67
+ const conv = (await c.json().catch(() => null));
68
+ // Provider (= clip alias) for prompt labeling + the reply hint.
69
+ // Best-effort: an unresolved provider degrades the wording, never blocks.
70
+ const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
71
+ const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
72
+ const senderName = msg?.external_user_name || msg?.external_user_id || 'external user';
73
+ const externalConversationId = conv?.external_conversation_id
74
+ ? String(conv.external_conversation_id)
75
+ : undefined;
76
+ const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
77
+ // Progress reporting — identical ordering contract to the message path
78
+ // (see the comments there): received settles before this function returns;
79
+ // the turnEnvelope marker goes down before any session write; the row
80
+ // stays 'received' until the turn event's complete-sources.
81
+ const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
82
+ const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
83
+ const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
84
+ let sessionId = await ensureSession(ctx);
85
+ if (sessionId) {
86
+ await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => { });
87
+ const report = (sid) => Promise.all([
88
+ patchSession(ctx, sid, { status: 'active', trigger_message_id: sourceId }),
89
+ createChannelInputStep(ctx, sid, {
90
+ conversationId,
91
+ channelMessageId: sourceId,
92
+ provider,
93
+ externalConversationId,
94
+ senderId: msg?.external_user_id ? String(msg.external_user_id) : 'external',
95
+ senderName,
96
+ text,
97
+ sentAt: msg?.received_at ? String(msg.received_at) : undefined,
98
+ }),
99
+ ]);
100
+ const results = await report(sessionId);
101
+ if (results.includes('stale')) {
102
+ await invalidateSession(ctx);
103
+ const fresh = await ensureSession(ctx);
104
+ if (fresh && fresh !== sessionId) {
105
+ await report(fresh);
106
+ sessionId = fresh;
107
+ }
108
+ }
109
+ // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
110
+ // conversation into its session; last-write-wins on the server). Only on
111
+ // change — the store remembers what was last pushed — and fully
112
+ // fire-and-forget: pure bookkeeping must never add latency to the
113
+ // emit/ack path (a lost attempt is retried on the next message because
114
+ // the store memo only updates on success).
115
+ void recordConversationSession(ctx, conversationId, sessionId).catch(() => { });
116
+ }
117
+ await receivedSettled;
118
+ await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId);
119
+ return [
120
+ {
121
+ type: 'emitEvent',
122
+ event: {
123
+ id: envelopeId,
124
+ orgId,
125
+ connectionId: ctx.connectionId,
126
+ source: 'parall',
127
+ // The host's message intake path turns data.text into the user
128
+ // message — the external-audience framing rides inside it (parel has
129
+ // no separate prompt channel), so 'message' is the right type here.
130
+ type: 'message',
131
+ // main routing sends this to the agent's one session too; subject
132
+ // still carries the external conversation (chv_) for parel's turn
133
+ // input grouping (F2) and per-conversation event correlation.
134
+ subject: conversationId,
135
+ data: {
136
+ text: buildChannelPrompt({
137
+ provider,
138
+ conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
139
+ externalConversationId,
140
+ externalMessageId,
141
+ senderName,
142
+ text,
143
+ }),
144
+ conversationId,
145
+ channelMessageId: sourceId,
146
+ provider,
147
+ externalConversationId,
148
+ externalMessageId,
149
+ },
150
+ // No chatId on purpose: the reply goes out through the provider clip
151
+ // (agent-invoked), NOT deliver() — deliver only runs the redundant
152
+ // session idle for channel routes (see delivery.ts).
153
+ replyRoute: {
154
+ kind: 'provider_http',
155
+ connectionId: ctx.connectionId,
156
+ data: { conversationId, envelopeId },
157
+ },
158
+ // No invocation context: PRLL_CHAT_ID must not point CLI defaults at
159
+ // a chat that doesn't exist, and the reply targets (external ids)
160
+ // already ride the prompt verbatim. (So no PRLL_DISPATCH_SOURCE_*
161
+ // either — the external reply goes out through the provider clip,
162
+ // and this row's ledger close is the turn event's complete-sources.)
163
+ },
164
+ },
165
+ // No ack effect — see the message path (inbound.ts): the row stays
166
+ // received until complete-sources closes it.
167
+ ];
168
+ }
169
+ /**
170
+ * connection id → provider alias, cached in the durable store (stable
171
+ * mapping; avoids one connection fetch per inbound message). Cache hits are
172
+ * a store read; any failure returns undefined — prompt wording degrades to
173
+ * neutral rather than inventing a clip alias.
174
+ */
175
+ async function resolveChannelProvider(ctx, connectionId, get) {
176
+ const cacheKey = `channelProvider:${connectionId}`;
177
+ const cached = await ctx.store?.get(cacheKey).catch(() => null);
178
+ if (typeof cached === 'string' && cached)
179
+ return cached;
180
+ try {
181
+ const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
182
+ if (!res.ok)
183
+ return undefined;
184
+ const body = (await res.json().catch(() => null));
185
+ const provider = body?.provider ? String(body.provider) : undefined;
186
+ if (provider)
187
+ await ctx.store?.set(cacheKey, provider).catch(() => { });
188
+ return provider;
189
+ }
190
+ catch {
191
+ return undefined;
192
+ }
193
+ }
194
+ /** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
195
+ async function recordConversationSession(ctx, conversationId, sessionId) {
196
+ const mappedKey = `convSession:${conversationId}`;
197
+ const mapped = await ctx.store?.get(mappedKey).catch(() => null);
198
+ if (mapped === sessionId)
199
+ return;
200
+ try {
201
+ const res = await fetch(`${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`, {
202
+ method: 'PATCH',
203
+ headers: {
204
+ Authorization: `Bearer ${requireAgk(ctx)}`,
205
+ 'Content-Type': 'application/json',
206
+ },
207
+ body: JSON.stringify({ agent_session_id: sessionId }),
208
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
209
+ });
210
+ if (!res.ok) {
211
+ console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
212
+ return;
213
+ }
214
+ await ctx.store?.set(mappedKey, sessionId).catch(() => { });
215
+ }
216
+ catch (err) {
217
+ console.warn('[parel-channel] conversation session mapping failed', err);
218
+ }
219
+ }
package/dist/inbound.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import type { AgentEvent, AgentEventEffect, ConnectorContext, ConnectorEffect, WebSocketFrame } from '@parel/plugin-sdk';
2
+ /** Surface a violated isolation assumption as a log line, not silent
3
+ * mis-serialization (see the chain doc above). */
4
+ export declare function assertChainConnection(ctx: ConnectorContext): void;
5
+ export declare function serialized<T>(fn: () => Promise<T>): Promise<T>;
2
6
  export declare function handleParallFrame(frame: WebSocketFrame, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
7
+ /**
8
+ * Fork retry timers (armed by child_spawn_failed): re-plan the stranded
9
+ * message through the normal dispatch pipeline. onTimer returns the FULL
10
+ * effect union (unlike onAgentEvent), so the re-plan can emit/spawn/ack.
11
+ * The parked row is cleared first — a re-plan that fails transiently leaves
12
+ * the WorkItem in `received` for the reconnect sweep, same as any dispatch.
13
+ */
14
+ export declare function handleParallTimer(timer: {
15
+ key: string;
16
+ }, ctx: ConnectorContext): Promise<ConnectorEffect[]>;
3
17
  /**
4
18
  * Sweep pending dispatches on (re)connect — the live WS push has no replay.
5
19
  * Paginates with the server cursor so a long outage's backlog is not silently
@@ -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;AAiD3B,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAiB5B;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAuDrF;AAkdD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAyD7B"}
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;AAuH3B;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;AAwpBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAE7B"}