@parall/parel-channel 1.51.0 → 1.52.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/catchup.ts ADDED
@@ -0,0 +1,337 @@
1
+ import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
2
+ import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
3
+ import { clearForkState, FORK_DISABLED_KEY } from './fork.js';
4
+ import { fetchMainSessionFresh, invalidateChildSession, SESSION_CACHE_KEY } from './session.js';
5
+
6
+ /**
7
+ * Incremental, timer-driven dispatch catch-up (execution-model v2 I4:
8
+ * lifecycle hooks are O(1); any O(backlog) work must be a durable, resumable,
9
+ * timer-driven job — parel channel-connector-execution-model.md §2).
10
+ *
11
+ * The 1.48.0 shape — onOpen sweeping up to 40×50 dispatches inline — cannot
12
+ * live under the v2 15s onOpen budget (a backlog would blow the budget, and
13
+ * under first-timeout-terminal semantics every timeout is an epoch rotation:
14
+ * the sweep would convert a reconnect into a rotation storm). Instead:
15
+ *
16
+ * onOpen → reset the sweep state, arm CATCHUP_TIMER_KEY at now (O(1))
17
+ * onTimer → one bounded round per firing:
18
+ * round 0 (phase 'gen'): the New Session generation check — MUST precede
19
+ * any dispatch replay (a rotation that happened while offline would
20
+ * otherwise replay post-reset messages into pre-reset forks), plus the
21
+ * forkDisabled revalidation-latch clear. Best-effort, same posture the
22
+ * onOpen sweep had: a transient failure logs and the sweep proceeds
23
+ * (the cached session id — the generation token — is never dropped).
24
+ * sweep rounds: ONE page of GET /dispatch, items replayed FIFO through
25
+ * the normal dispatch pipeline, then re-arm immediately while pages
26
+ * remain. Cursor lives in ctx.store; it is an optimization, not a
27
+ * correctness source — the per-source attempt fence (shouldSkipLiveRow)
28
+ * makes any re-scan idempotent, so a mid-page bail or a crash simply
29
+ * re-lists the same page and skips what already emitted.
30
+ * drain: a pass that ends (no next_cursor) after emitting anything runs
31
+ * one more pass from the top (rows the cursor walk missed, rows that
32
+ * arrived mid-sweep); the job ends on the first CLEAN pass — no emits,
33
+ * no item failures. Item failures keep the row pending server-side and
34
+ * re-arm the next pass with exponential backoff instead of ending, so
35
+ * a transiently unfetchable source is retried without a reconnect.
36
+ *
37
+ * FIFO across the live socket: while a sweep is in flight, dispatch.new
38
+ * frames are NOT processed inline (they would leapfrog the backlog into the
39
+ * shared main session out of order) — the frame handler just re-arms the
40
+ * timer (armCatchUpEffect) and lets the sweep list the row, which is already
41
+ * durable server-side. That re-arm doubles as the lost-timer self-heal: any
42
+ * new frame revives a stalled sweep. The re-arm honours an in-flight backoff
43
+ * (state.notBefore): a frame arriving while a round is backed off on a 5xx
44
+ * source re-arms at the remaining delay, not 0, so steady traffic against a
45
+ * failing source cannot tight-poll it.
46
+ *
47
+ * Runs identically under the v1 host (flag off): timers and effects work the
48
+ * same there, rounds serialize through the connection hook chain, and the
49
+ * bounded round size keeps each onTimer call small.
50
+ */
51
+
52
+ export const CATCHUP_TIMER_KEY = '__parall_catchup__';
53
+ /** Store key — deliberately OUTSIDE the `fork` prefix family so the
54
+ * agent.new_session purge (clearForkState) never wipes sweep state. */
55
+ const CATCHUP_STATE_KEY = 'catchup';
56
+
57
+ /** One page per round; page size == round item bound so a clean round always
58
+ * advances the cursor. Small on purpose: each item can fan out several inline
59
+ * fetches and 1-2 effects, and the round's effects execute while the v2
60
+ * execution slot is held — a big page would occupy it for the whole replay. */
61
+ const CATCHUP_PAGE_LIMIT = 10;
62
+ /** Soft wall-clock bound per round, checked BETWEEN items (an in-flight item
63
+ * finishes). 4s: the binding constraint is the LEGACY (flag-off) host, whose
64
+ * hook budget is 5s and whose timeout discards the round's effects — re-arm
65
+ * included — rather than cancelling the work; staying under it keeps the
66
+ * common case safe on both hosts (v2's onTimer budget is 60s). A single
67
+ * pathological item can still blow past any bound (its own fetch caps are
68
+ * the 5s REQUEST_TIMEOUT_MS); the self-heal for a discarded re-arm is the
69
+ * frame handler's defer path (any live frame re-arms the timer) plus the
70
+ * next reconnect's beginCatchUp. */
71
+ const ROUND_SOFT_BUDGET_MS = 4_000;
72
+ /** Timeout for the job's OWN fetches (dispatch page list, generation check).
73
+ * Tighter than REQUEST_TIMEOUT_MS: on the v1 host these run inside a 5s
74
+ * onTimer budget whose expiry discards the round's effects AFTER the due
75
+ * timer row was already deleted — a single stalled request at the full 5s
76
+ * cap would strand the job with no timer. 3s leaves re-arm margin. */
77
+ const CATCHUP_FETCH_TIMEOUT_MS = 3_000;
78
+ const BACKOFF_BASE_MS = 5_000;
79
+ const BACKOFF_MAX_MS = 5 * 60 * 1000;
80
+
81
+ interface CatchUpState {
82
+ phase: 'gen' | 'sweep';
83
+ cursor: string;
84
+ /** This pass emitted work or saw item failures — another pass follows. */
85
+ dirty: boolean;
86
+ /** Item failures this pass — the follow-up pass arms with backoff. */
87
+ sawFailure: boolean;
88
+ /** Consecutive transient failures (page fetch / failed passes) → backoff. */
89
+ failures: number;
90
+ /** Absolute ctx.now() before which the sweep timer must NOT fire, or 0 when
91
+ * unconstrained. Persisted so the FIFO defer path (a live frame re-arming
92
+ * the keyed timer) honours an in-flight backoff instead of overwriting it
93
+ * to fire immediately — otherwise a source stuck on 5xx plus steady traffic
94
+ * would tight-poll the failing API, the exact storm backoffDelay prevents. */
95
+ notBefore: number;
96
+ }
97
+
98
+ interface DispatchPage {
99
+ data?: Record<string, unknown>[];
100
+ next_cursor?: string;
101
+ }
102
+
103
+ /** Frozen so the store-less fallback (memoryState, written by reference in
104
+ * beginCatchUp) cannot be mutated into a corrupted shared template — every
105
+ * transition already builds a new object via spread. */
106
+ const FRESH_STATE: CatchUpState = Object.freeze({
107
+ phase: 'gen',
108
+ cursor: '',
109
+ dirty: false,
110
+ sawFailure: false,
111
+ failures: 0,
112
+ notBefore: 0,
113
+ });
114
+
115
+ /** In-memory fallback for store-less contexts (SDK test mocks) — a module
116
+ * global is a discardable cache, never correctness (v2 I4), which is exactly
117
+ * the durability class a fallback may have. */
118
+ const memoryState = new Map<string, CatchUpState>();
119
+
120
+ async function readState(ctx: ConnectorContext): Promise<CatchUpState | null> {
121
+ if (!ctx.store) return memoryState.get(ctx.connectionId) ?? null;
122
+ const raw = (await ctx.store.get(CATCHUP_STATE_KEY).catch(() => null)) as CatchUpState | null;
123
+ return raw && (raw.phase === 'gen' || raw.phase === 'sweep') ? raw : null;
124
+ }
125
+
126
+ async function writeState(ctx: ConnectorContext, state: CatchUpState): Promise<void> {
127
+ if (!ctx.store) {
128
+ memoryState.set(ctx.connectionId, state);
129
+ return;
130
+ }
131
+ await ctx.store.set(CATCHUP_STATE_KEY, state).catch(() => {});
132
+ }
133
+
134
+ async function clearState(ctx: ConnectorContext): Promise<void> {
135
+ if (!ctx.store) {
136
+ memoryState.delete(ctx.connectionId);
137
+ return;
138
+ }
139
+ await ctx.store.delete(CATCHUP_STATE_KEY).catch(() => {});
140
+ }
141
+
142
+ /** The idempotent "make sure the sweep timer is armed" effect (setTimer is
143
+ * keyed — re-arming replaces). Also what the frame handler returns while a
144
+ * sweep is in flight. */
145
+ export function armCatchUpEffect(ctx: ConnectorContext, delayMs = 0): ConnectorEffect {
146
+ return { type: 'setTimer', key: CATCHUP_TIMER_KEY, at: ctx.now() + delayMs };
147
+ }
148
+
149
+ /**
150
+ * onOpen: O(1) by contract — reset the sweep to a fresh pass (a reconnect is
151
+ * the revalidation point, so the generation check must run again) and arm the
152
+ * timer. All real work happens in runCatchUpRound.
153
+ */
154
+ export async function beginCatchUp(ctx: ConnectorContext): Promise<ConnectorEffect[]> {
155
+ await writeState(ctx, FRESH_STATE);
156
+ return [armCatchUpEffect(ctx)];
157
+ }
158
+
159
+ /**
160
+ * The frame handler's FIFO gate: while a sweep is in flight (state present),
161
+ * a live dispatch.new must not leapfrog the backlog. Returns the delay (ms)
162
+ * the caller should re-arm the sweep timer with — honouring any in-flight
163
+ * backoff (notBefore) so a live frame cannot reset a backed-off round to fire
164
+ * immediately — or null when no sweep is in flight (process the frame inline).
165
+ * Also marks the pass dirty so the job runs another from-the-top pass before
166
+ * ending. The dirty mark is load-bearing, not just ordering hygiene: a server
167
+ * REDRIVE hint can point at a row BEHIND the sweep cursor (e.g. during the
168
+ * final re-check pass), and deferring the frame consumes that hint — without
169
+ * forcing one more full pass, the job could end cleanly with the row
170
+ * stranded until the next reconnect. Runs inside the serialized chain, so
171
+ * the read-modify-write cannot interleave with a round's own state write.
172
+ */
173
+ export async function deferToCatchUp(ctx: ConnectorContext): Promise<number | null> {
174
+ const state = await readState(ctx);
175
+ if (!state) return null;
176
+ if (!state.dirty) await writeState(ctx, { ...state, dirty: true });
177
+ // Honour an in-flight backoff: re-arming the keyed timer at delay 0 would
178
+ // overwrite a backoffDelay-armed round and tight-poll a failing source.
179
+ return Math.max(0, state.notBefore - ctx.now());
180
+ }
181
+
182
+ function backoffDelay(failures: number): number {
183
+ return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_MAX_MS);
184
+ }
185
+
186
+ /**
187
+ * New Session generation check — verbatim semantics of the 1.48.0 onOpen
188
+ * preamble (see the session.ts fetchMainSessionFresh doc): the
189
+ * `agent.new_session` WS push is not replayed, so a reset that happened while
190
+ * disconnected leaves stale fork routing that would deliver post-reset
191
+ * messages into pre-reset children. The main ase_ id IS the generation token.
192
+ */
193
+ async function runGenerationCheck(ctx: ConnectorContext): Promise<void> {
194
+ try {
195
+ const cachedMain = (await ctx.store?.get(SESSION_CACHE_KEY).catch(() => null)) as string | null;
196
+ if (cachedMain) {
197
+ const freshMain = await fetchMainSessionFresh(ctx, { timeoutMs: CATCHUP_FETCH_TIMEOUT_MS });
198
+ if (freshMain && freshMain !== cachedMain) {
199
+ const refs = await clearForkState(ctx);
200
+ for (const ref of refs) {
201
+ await invalidateChildSession(ctx, ref);
202
+ }
203
+ console.warn('[parel-channel] main session rotated while offline — fork state purged');
204
+ }
205
+ }
206
+ // Capability revalidation: a parked forkDisabled latch reflects the
207
+ // binding shape at failure time; the fleet sweep converges bindings
208
+ // without restarting the connection, so a reconnect is the natural
209
+ // revalidation point. If forking is truly still disabled, the first
210
+ // spawn attempt re-parks the latch at the cost of one failed effect.
211
+ await ctx.store?.delete(FORK_DISABLED_KEY).catch(() => {});
212
+ } catch (err) {
213
+ console.error('[parel-channel] new-session generation check failed', err);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * One timer round. `planDispatch` is the normal inbound dispatch pipeline
219
+ * (inbound.ts effectsForDispatch), injected to keep this module's imports
220
+ * acyclic. Every path either re-arms the timer or deliberately ends the job.
221
+ */
222
+ export async function runCatchUpRound(
223
+ ctx: ConnectorContext,
224
+ planDispatch: (data: Record<string, unknown>) => Promise<ConnectorEffect[]>,
225
+ ): Promise<ConnectorEffect[]> {
226
+ // A missing state row (timer armed by a deferred frame racing the sweep's
227
+ // clean finish, or state lost) starts a fresh sweep — over-sweeping is
228
+ // idempotent (fences), under-sweeping loses messages.
229
+ const state = (await readState(ctx)) ?? FRESH_STATE;
230
+
231
+ if (state.phase === 'gen') {
232
+ await runGenerationCheck(ctx);
233
+ await writeState(ctx, { ...FRESH_STATE, phase: 'sweep' });
234
+ return [armCatchUpEffect(ctx)];
235
+ }
236
+
237
+ const roundStart = ctx.now();
238
+ let body: DispatchPage | null = null;
239
+ try {
240
+ const apiUrl = parallApiUrl(ctx);
241
+ const agk = requireAgk(ctx);
242
+ const orgId = parallOrgId(ctx);
243
+ const url = `${apiUrl}/api/v1/orgs/${orgId}/dispatch?limit=${CATCHUP_PAGE_LIMIT}${
244
+ state.cursor ? `&cursor=${encodeURIComponent(state.cursor)}` : ''
245
+ }`;
246
+ const res = await fetch(url, {
247
+ headers: { Authorization: `Bearer ${agk}` },
248
+ signal: AbortSignal.timeout(CATCHUP_FETCH_TIMEOUT_MS),
249
+ });
250
+ if (res.ok) {
251
+ body = (await res.json().catch(() => null)) as DispatchPage | null;
252
+ }
253
+ } catch {
254
+ // fall through to the transient-failure backoff below
255
+ }
256
+ if (!body) {
257
+ const failures = state.failures + 1;
258
+ const delay = backoffDelay(failures);
259
+ // Persist notBefore so a live frame's defer re-arm honours the backoff
260
+ // instead of resetting it to fire now (tight-polling the failing API).
261
+ await writeState(ctx, { ...state, failures, notBefore: ctx.now() + delay });
262
+ console.error(`[parel-channel] catch-up page fetch failed (attempt ${failures})`);
263
+ return [armCatchUpEffect(ctx, delay)];
264
+ }
265
+
266
+ // Replay SEQUENTIALLY, in page order (FIFO): every envelope feeds the
267
+ // agent's one main session, so cross-chat replay concurrency would
268
+ // scramble the session's input order AND race the agent-global
269
+ // turnEnvelope marker. Transient item failures THROW (the pipeline's
270
+ // transient-vs-deliberate contract: a 5xx/timeout on a source fetch
271
+ // throws; deliberate pendings — unknown types, fence skips, disabled
272
+ // deployments — return []): the row stays pending server-side and the
273
+ // failure flag re-arms the next pass with backoff instead of ending the
274
+ // job with the row stranded on a healthy long-lived socket.
275
+ const rows = body.data ?? [];
276
+ const effects: ConnectorEffect[] = [];
277
+ let dirty = state.dirty;
278
+ let sawFailure = state.sawFailure;
279
+ let processed = 0;
280
+ for (const d of rows) {
281
+ if (ctx.now() - roundStart > ROUND_SOFT_BUDGET_MS) break;
282
+ const out = await planDispatch(d).catch((err) => {
283
+ console.error('[parel-channel] catch-up dispatch failed', err);
284
+ sawFailure = true;
285
+ dirty = true;
286
+ return [] as ConnectorEffect[];
287
+ });
288
+ // Only MAIN-path emits drive the drain re-check: they arm a fence, so
289
+ // the follow-up pass skips them and the job converges. Fork-owned
290
+ // outputs (spawnChildSession / deliverTo emits) never write a fence —
291
+ // counting them would re-check forever, and their fast re-plan would
292
+ // race the fork verification cadence — their loss recovery belongs to
293
+ // the fork machinery (parked acks, retry timer), not this job.
294
+ if (out.some((e) => e.type === 'emitEvent' && !e.deliverTo)) {
295
+ dirty = true;
296
+ }
297
+ effects.push(...out);
298
+ processed++;
299
+ }
300
+
301
+ if (processed < rows.length) {
302
+ // Soft budget hit mid-page: keep the cursor (the refetch re-lists this
303
+ // page; already-emitted rows fence-skip) and continue immediately. The
304
+ // failure streak is NOT reset mid-pass — only a failure-free pass end
305
+ // clears it, or a persistent 5xx in a multi-page backlog would compute
306
+ // a streak of 1 forever and rescan at the base delay instead of backing
307
+ // off. The re-arm timer leads the effect list: v2 replays effects in
308
+ // order and a dead-lettered earlier effect must not also cost the job
309
+ // its progression timer (setTimer is host-owned/idempotent — order is
310
+ // free on v1).
311
+ // Progress this round → no backoff: clear notBefore so a lingering value
312
+ // from an earlier failed round can't make a defer honour a stale backoff.
313
+ await writeState(ctx, { ...state, dirty, sawFailure, notBefore: 0 });
314
+ return [armCatchUpEffect(ctx), ...effects];
315
+ }
316
+ if (body.next_cursor) {
317
+ await writeState(ctx, { ...state, cursor: body.next_cursor, dirty, sawFailure, notBefore: 0 });
318
+ return [armCatchUpEffect(ctx), ...effects];
319
+ }
320
+
321
+ // Pass complete. Clean pass (nothing emitted, nothing failed) ends the job;
322
+ // a dirty pass runs another full pass from the top — the re-check that
323
+ // makes the cursor an optimization instead of a correctness source.
324
+ if (!dirty) {
325
+ await clearState(ctx);
326
+ return effects;
327
+ }
328
+ const failures = sawFailure ? state.failures + 1 : 0;
329
+ const delay = sawFailure ? backoffDelay(failures) : 0;
330
+ await writeState(ctx, {
331
+ ...FRESH_STATE,
332
+ phase: 'sweep',
333
+ failures,
334
+ notBefore: delay > 0 ? ctx.now() + delay : 0,
335
+ });
336
+ return [armCatchUpEffect(ctx, delay), ...effects];
337
+ }
package/src/connect.ts CHANGED
@@ -14,11 +14,13 @@ export async function connectParall(ctx: ConnectorContext): Promise<ConnectReque
14
14
  const apiUrl = parallApiUrl(ctx);
15
15
  const agk = requireAgk(ctx);
16
16
  // connect() runs on every (re)connect — fail fast rather than hang the host's
17
- // reconnect loop on a wedged ticket endpoint.
17
+ // reconnect loop on a wedged ticket endpoint. 5s: the v2 connect budget is
18
+ // 15s and first-timeout rotates the epoch, so the single mint fetch must
19
+ // leave ample margin (session.ts REQUEST_TIMEOUT_MS rationale).
18
20
  const res = await fetch(`${apiUrl}/api/v1/ws/ticket`, {
19
21
  method: 'POST',
20
22
  headers: { Authorization: `Bearer ${agk}` },
21
- signal: AbortSignal.timeout(10_000),
23
+ signal: AbortSignal.timeout(5_000),
22
24
  });
23
25
  if (!res.ok) {
24
26
  throw new Error(`parel-channel: ws ticket failed (${res.status})`);
package/src/fork.ts CHANGED
@@ -59,7 +59,8 @@ const MAX_ACK_ATTEMPTS = 3;
59
59
 
60
60
  const MAIN_TURN_KEY = 'forkMainTurn';
61
61
  export const FORK_RETRY_PREFIX = 'forkRetry:';
62
- const FORK_DISABLED_KEY = 'forkDisabled';
62
+ /** Exported for the catch-up job's reconnect revalidation (catchup.ts). */
63
+ export const FORK_DISABLED_KEY = 'forkDisabled';
63
64
  const childKey = (subject: string) => `forkChild:${subject}`;
64
65
  const childSubjectKey = (childRef: string) => `forkChildSubject:${childRef}`;
65
66
 
@@ -249,6 +250,11 @@ export async function clearForkSubject(
249
250
  /**
250
251
  * Bump-and-park a fork retry ledger row (also the spawn-verification
251
252
  * coordinate onTimer re-plans). Returns the attempt count INCLUDING this one.
253
+ * Attempts count VERIFICATION ROUNDS on the retry timer's cadence, so a bump
254
+ * younger than FORK_RETRY_DELAY_MS is a fast replay (catch-up re-check pass,
255
+ * rapid reconnect double-sweep) and keeps the prior count — without the
256
+ * floor, back-to-back replays would burn through MAX_SPAWN_VERIFY_ATTEMPTS
257
+ * in milliseconds and black-hole a child that was starting up fine.
252
258
  */
253
259
  export async function bumpForkRetryAttempts(
254
260
  ctx: ConnectorContext,
@@ -257,9 +263,18 @@ export async function bumpForkRetryAttempts(
257
263
  ): Promise<number> {
258
264
  const prior = (await ctx.store?.get(retryKey).catch(() => null)) as {
259
265
  attempts?: number;
266
+ at?: number;
260
267
  } | null;
268
+ const now = ctx.now();
269
+ if (
270
+ typeof prior?.attempts === 'number' &&
271
+ typeof prior.at === 'number' &&
272
+ now - prior.at < FORK_RETRY_DELAY_MS
273
+ ) {
274
+ return prior.attempts;
275
+ }
261
276
  const attempts = (prior?.attempts ?? 0) + 1;
262
- await ctx.store?.set(retryKey, { ...data, attempts }).catch(() => {});
277
+ await ctx.store?.set(retryKey, { ...data, attempts, at: now }).catch(() => {});
263
278
  return attempts;
264
279
  }
265
280
 
@@ -64,7 +64,10 @@ export async function effectsForChannelDispatch(
64
64
  // takes) — permanently unusable, drop it.
65
65
  return [completeSourceEffect(ctx, 'channel_message', sourceId)];
66
66
  }
67
- return []; // transient: keep pending, the catch-up sweep retries
67
+ // Transient: keep pending — THROW so the catch-up job counts the row as
68
+ // starving and re-arms with backoff (transient-vs-deliberate contract,
69
+ // see inbound.ts message path).
70
+ throw new Error(`transient channel message fetch failure (${m.status})`);
68
71
  }
69
72
  const msg = (await m.json().catch(() => null)) as {
70
73
  conversation_id?: string;
@@ -92,7 +95,7 @@ export async function effectsForChannelDispatch(
92
95
  if (c.status === 404 || c.status === 403) {
93
96
  return [completeSourceEffect(ctx, 'channel_message', sourceId)];
94
97
  }
95
- return [];
98
+ throw new Error(`transient channel conversation fetch failure (${c.status})`);
96
99
  }
97
100
  const conv = (await c.json().catch(() => null)) as {
98
101
  connection_id?: string;
@@ -118,6 +121,7 @@ export async function effectsForChannelDispatch(
118
121
  const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
119
122
  const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
120
123
  const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
124
+ let mappingEffect: ConnectorEffect | null = null;
121
125
  let sessionId = await ensureSession(ctx);
122
126
  if (sessionId) {
123
127
  await ctx.store?.set(TURN_ENVELOPE_KEY, envelopeId).catch(() => {});
@@ -137,23 +141,40 @@ export async function effectsForChannelDispatch(
137
141
  ]);
138
142
  const results = await report(sessionId);
139
143
  if (results.includes('stale')) {
144
+ // Same stale-repair contract as the chat path — and the same
145
+ // attribution rule: never leave sessionId pointing at the closed
146
+ // session (it feeds the fence below).
140
147
  await invalidateSession(ctx);
148
+ sessionId = null;
141
149
  const fresh = await ensureSession(ctx);
142
- if (fresh && fresh !== sessionId) {
143
- await report(fresh);
144
- sessionId = fresh;
150
+ if (fresh) {
151
+ const retry = await report(fresh);
152
+ if (!retry.includes('stale')) sessionId = fresh;
145
153
  }
146
154
  }
147
155
  // Record the durable chv_ ↔ ase_ mapping (ops drill-down from a
148
- // conversation into its session; last-write-wins on the server). Only on
149
- // change — the store remembers what was last pushed — and fully
150
- // fire-and-forget: pure bookkeeping must never add latency to the
151
- // emit/ack path (a lost attempt is retried on the next message because
152
- // the store memo only updates on success).
153
- void recordConversationSession(ctx, conversationId, sessionId).catch(() => {});
156
+ // conversation into its session; last-write-wins on the server) as a
157
+ // FETCH EFFECT: host-executed, so it costs the hook budget nothing on
158
+ // the v1 host (a stalled PATCH awaited in-hook could blow the 5s budget
159
+ // and cost the emit), and the v2 outbox replays it with the idempotency
160
+ // key. The memo is written up front (inside the capture window): a
161
+ // dropped effect leaves the mapping stale until the session id next
162
+ // rotates — acceptable for an ops-observability pointer. Skipped when
163
+ // the stale repair above dropped the session — the next message records
164
+ // the fresh one.
165
+ if (sessionId) {
166
+ const mappedKey = `convSession:${conversationId}`;
167
+ const mapped = await ctx.store?.get(mappedKey).catch(() => null);
168
+ if (mapped !== sessionId) {
169
+ await ctx.store?.set(mappedKey, sessionId).catch(() => {});
170
+ mappingEffect = conversationSessionEffect(ctx, conversationId, sessionId);
171
+ }
172
+ }
154
173
  }
155
174
  await receivedSettled;
156
- await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId);
175
+ // Fence carries this attempt's session for turn-end attribution (see
176
+ // ActiveEmit) — same contract as the chat-message path.
177
+ await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId, sessionId ?? undefined);
157
178
 
158
179
  // Live capability grant → this turn's lark-cli auth env (mint gate = the
159
180
  // no-drift revocation point) + the hint teaching the single reply path.
@@ -208,9 +229,35 @@ export async function effectsForChannelDispatch(
208
229
  },
209
230
  // No ack effect — see the message path (inbound.ts): the row stays
210
231
  // received until complete-sources closes it.
232
+ ...(mappingEffect ? [mappingEffect] : []),
211
233
  ];
212
234
  }
213
235
 
236
+ /**
237
+ * The chv_ → ase_ mapping push as a host-executed fetch effect (idempotent
238
+ * last-write-wins PATCH; the key dedupes v2 outbox replays of this exact
239
+ * pairing).
240
+ */
241
+ function conversationSessionEffect(
242
+ ctx: ConnectorContext,
243
+ conversationId: string,
244
+ sessionId: string,
245
+ ): Extract<ConnectorEffect, { type: 'fetch' }> {
246
+ return {
247
+ type: 'fetch',
248
+ request: {
249
+ url: `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`,
250
+ method: 'PATCH',
251
+ headers: {
252
+ Authorization: `Bearer ${requireAgk(ctx)}`,
253
+ 'Content-Type': 'application/json',
254
+ },
255
+ body: JSON.stringify({ agent_session_id: sessionId }),
256
+ },
257
+ idempotencyKey: `convmap:${conversationId}:${sessionId}`,
258
+ };
259
+ }
260
+
214
261
  /**
215
262
  * connection id → provider alias, cached in the durable store (stable
216
263
  * mapping; avoids one connection fetch per inbound message). Cache hits are
@@ -236,35 +283,3 @@ async function resolveChannelProvider(
236
283
  return undefined;
237
284
  }
238
285
  }
239
-
240
- /** Push the chv_ → ase_ pointer when it changed; warn-only on failure. */
241
- async function recordConversationSession(
242
- ctx: ConnectorContext,
243
- conversationId: string,
244
- sessionId: string,
245
- ): Promise<void> {
246
- const mappedKey = `convSession:${conversationId}`;
247
- const mapped = await ctx.store?.get(mappedKey).catch(() => null);
248
- if (mapped === sessionId) return;
249
- try {
250
- const res = await fetch(
251
- `${parallApiUrl(ctx)}/api/v1/orgs/${parallOrgId(ctx)}/channel-conversations/${conversationId}/session`,
252
- {
253
- method: 'PATCH',
254
- headers: {
255
- Authorization: `Bearer ${requireAgk(ctx)}`,
256
- 'Content-Type': 'application/json',
257
- },
258
- body: JSON.stringify({ agent_session_id: sessionId }),
259
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
260
- },
261
- );
262
- if (!res.ok) {
263
- console.warn(`[parel-channel] conversation session mapping failed (${res.status})`);
264
- return;
265
- }
266
- await ctx.store?.set(mappedKey, sessionId).catch(() => {});
267
- } catch (err) {
268
- console.warn('[parel-channel] conversation session mapping failed', err);
269
- }
270
- }