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